"""
cortex/config/model.py — CortexConfig dataclass.

Central configuration for the entire Cortex system. All modules receive
their tunable parameters through CortexConfig rather than hardcoding values.

Load order (later wins):
    1. Package defaults  (cortex/config/defaults.toml — shipped with the package)
    2. User config file  (path passed to from_toml())
    3. Environment variables  (CORTEX_<FIELD_NAME> — e.g., CORTEX_DB_PATH)
    4. In-process overlay dict  (e.g., CLI flags or test overrides)

Note: The cortex_config DB table stores config values for reference/audit but
is NOT read back at runtime. Config is loaded once from TOML at startup.
There is no runtime hot-reload mechanism.

Design decisions:
    - Flat dataclass (no nested objects) for simplicity — section prefixes
      (storage_, retrieval_, etc.) group related fields.
    - Every field has a sane default, so CortexConfig() with no args works.
    - TOML sections are flattened during _from_raw() via explicit mapping.

Dependencies: None (leaf module).
Dependents: Every module that needs tunable parameters — all of them.
"""
from __future__ import annotations

import os
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any, Dict, List, Optional

from cortex.types import CortexTier

from cortex.config.utils import _coerce_env_value, _deep_merge

# Python 3.11+ has tomllib in stdlib; 3.10 and below need tomli (third-party).
try:
    import tomllib  # type: ignore
except ImportError:
    try:
        import tomli as tomllib  # type: ignore
    except ImportError:
        tomllib = None  # type: ignore


_DEFAULTS_PATH = Path(__file__).parent / "defaults.toml"

#: Default embedding dimension shared between CortexConfig and SQLiteAdapter.
#: Must match the output dimension of the configured embedding model.
DEFAULT_EMBEDDING_DIM: int = 1024


@dataclass
class CortexConfig:
    """
    Runtime configuration for Cortex v3.

    Configuration is loaded once from TOML at process startup. The
    cortex_config DB table mirrors these values for reference/audit but
    is not read back at runtime — there is no hot-reload mechanism.

    Fields with a ``storage_`` prefix come from the [cortex.storage] TOML
    section; they are handled separately because they affect DB connection
    setup (before the table exists).
    """

    # ------------------------------------------------------------------ #
    # Storage / connection (M1)
    # These affect DB connection setup — read BEFORE the DB is opened.
    # ------------------------------------------------------------------ #
    storage_backend: str = "sqlite"
    """Storage backend type. Only 'sqlite' is implemented; 'postgres' planned for Phase 5."""

    storage_db_path: str = "cortex.db"
    """Path to SQLite database file. Relative paths resolved against cwd or base arg."""

    storage_enable_wal: bool = True
    """Enable Write-Ahead Logging for concurrent read performance. Almost always True."""

    storage_busy_timeout_ms: int = 5000
    """SQLite busy timeout in ms. Prevents SQLITE_BUSY on concurrent access."""

    # ------------------------------------------------------------------ #
    # Embeddings (used by M8 retrieval + M11 drift detection)
    # ------------------------------------------------------------------ #
    embedding_model: str = "text-embedding-3-small"
    """Model ID for vector embeddings. Must match what StorageAdapter stores."""

    embedding_dim: int = DEFAULT_EMBEDDING_DIM
    """Dimensionality of embedding vectors. Must match the model's output dim."""

    embedding_model_lock_enabled: bool = True
    """Guard against silent embedding incompatibility on model change.

    When True (default), SQLiteAdapter checks at startup that the embedding
    model stored in cortex_config matches the currently-configured model.
    If they differ, startup is aborted with a clear error message instructing
    the operator to run a re-embedding migration first.

    Set to False (or env var CORTEX_EMBEDDING_MODEL_LOCK_ENABLED=false) only
    when intentionally performing a re-embedding migration run — never in
    normal production operation.
    """

    # ------------------------------------------------------------------ #
    # LLM provider cascade (M13 — CortexPlugin provider selection)
    # ------------------------------------------------------------------ #
    llm_provider_priority: List[str] = field(
        default_factory=lambda: ["google", "venice", "openai", "anthropic"]
    )
    """Ordered list of LLM provider names to try during plugin init.

    Cortex tries each provider in order and uses the first one that
    initialises successfully (API key present, SDK installed). This lets
    operators pin a provider or define a fallback chain without code changes.

    Supported values: "anthropic", "openai", "ollama".

    DEPRECATED in v1.2.1: Use provider_mode + provider_llm_* fields instead.
    Kept for backward compatibility — if [cortex.llm] section exists in config,
    a deprecation warning is logged and old fields are mapped to new ones.
    """

    # ------------------------------------------------------------------ #
    # Provider architecture (v1.2.1 — replaces provider cascade)
    # ------------------------------------------------------------------ #
    provider_mode: str = "host"
    """Provider mode: 'host' (OpenClaw plugin), 'byok' (user keys), 'bundled' (cloud managed)."""

    # Provider LLM config
    provider_llm_provider: str = ""
    """LLM provider name: openai | anthropic | venice | ollama. Auto-detected in host mode."""

    provider_llm_model: str = "gemini-2.5-flash"
    """Model used for extraction/reconciliation in the new provider system."""

    provider_llm_api_key: str = ""
    """LLM API key. Not needed in host mode (supplied by host at runtime)."""

    provider_llm_base_url: str = ""
    """LLM API base URL. Not needed in host mode (supplied by host at runtime)."""

    # ------------------------------------------------------------------ #
    # Per-operation provider overrides (Issue #1247)
    # ------------------------------------------------------------------ #
    # When set, the corresponding operation uses a dedicated LLM provider
    # instance instead of the shared provider_llm_* provider. This allows
    # routing extraction to one provider (e.g. Bailian) and reconciliation
    # to another (e.g. Venice) without sharing rate limits.
    #
    # Fallback: if a per-operation field is empty, the main provider_llm_*
    # values are used (zero regression).
    # ------------------------------------------------------------------ #

    provider_extraction_base_url: str = ""
    """Base URL for extraction-specific LLM provider. Falls back to provider_llm_base_url."""

    provider_extraction_api_key: str = ""
    """API key for extraction-specific LLM provider. Falls back to provider_llm_api_key."""

    provider_reconciliation_base_url: str = ""
    """Base URL for reconciliation-specific LLM provider. Falls back to provider_llm_base_url."""

    provider_reconciliation_api_key: str = ""
    """API key for reconciliation-specific LLM provider. Falls back to provider_llm_api_key."""

    provider_dialectic_base_url: str = ""
    """Base URL for dialectic-specific LLM provider. Falls back to provider_llm_base_url."""

    provider_dialectic_api_key: str = ""
    """API key for dialectic-specific LLM provider. Falls back to provider_llm_api_key."""

    # Provider embedding config
    provider_embedding_provider: str = ""
    """Embedding provider name: openai | voyage | google. Auto-detected in host mode."""

    provider_embedding_model: str = "voyage-4-large"
    """Embedding model for vector search in the new provider system."""

    provider_embedding_api_key: str = ""
    """Embedding API key. Not needed in host mode (supplied by host at runtime)."""

    provider_embedding_base_url: str = ""
    """Embedding API base URL. Not needed in host mode."""

    provider_embedding_dim: int = 1024
    """Embedding dimension. Must match the model's output dim."""

    # Usage tracking
    provider_usage_enabled: bool = False
    """Enable per-owner token counting for tier enforcement (bundled mode)."""

    provider_usage_tier: str = ""
    """Subscription tier: pro | scale | enterprise. Used in bundled mode."""

    provider_usage_monthly_token_cap: int = 0
    """Monthly token cap. 0 = unlimited. Used in bundled mode."""

    provider_usage_monthly_embedding_cap: int = 0
    """Monthly embedding call cap. 0 = unlimited. Used in bundled mode."""

    # ------------------------------------------------------------------ #
    # Provider fallback chain (cortex.providers.fallback — v1.2.1+)
    # ------------------------------------------------------------------ #
    provider_fallback_chain: List[str] = field(
        default_factory=lambda: []
    )
    """Ordered list of provider names for the fallback chain.

    When set, cortex.providers.fallback.call_with_fallback() will try each
    provider in order. The first to succeed wins. Failures are logged.
    An empty list disables multi-provider fallback (single-provider mode).

    Example (cortex.toml)::

        [cortex.providers.fallback]
        chain = ["google", "openai", "anthropic"]

    Supported names: any provider name valid in llm_provider_priority.
    """

    circuit_breaker_cooldown_s: int = 60
    """Cooldown in seconds before a tripped circuit breaker transitions to
    HALF_OPEN (probe state) in the cortex.providers.fallback chain.

    This is separate from the legacy circuit_breaker_cooldown_sec (300s default)
    used by the old cortex.llm.circuit_breaker. The shorter default (60s) is
    appropriate for fast fallback chains in the new provider architecture.

    TOML key: [cortex.providers.fallback] cooldown_s
    """

    # ------------------------------------------------------------------ #
    # LLM models (used by M2 extraction + M4 reconciliation)
    # ------------------------------------------------------------------ #
    extraction_model: str = "gemini-2.5-flash"
    """Model used by ExtractionPipeline for claim extraction. Nano = cheap + fast."""

    reconciliation_model: str = "gemini-2.5-flash"
    """Model used by ReconciliationEngine for dedup decisions. Can be different from extraction."""

    dialectic_model: Optional[str] = None
    """Model used by DialecticEngine and QueryPlanner for synthesis and planning.

    When None (default), falls back to ``extraction_model``. Set this to a
    more capable model (e.g. gpt-4.1-mini) when dialectic quality matters more
    than cost, without affecting the cheaper extraction path.
    """

    # ------------------------------------------------------------------ #
    # Token budgets (M9 — TokenBudgetManager)
    # These control how much of the LLM context window is allocated to memory.
    # ------------------------------------------------------------------ #
    cornerstone_token_budget: int = 800
    """Tokens reserved exclusively for cornerstone injection. Always allocated first."""

    retrieval_token_budget: int = 4000
    """Max tokens for retrieved context (semantic + session + procedural layers)."""

    total_memory_token_budget: int = 5000
    """Hard ceiling for ALL memory content (cornerstones + retrieval). Must be
    ≥ cornerstone_token_budget + retrieval_token_budget."""

    # Issue #374 — hard context-window budget fractions
    token_budget_memory_fraction: float = 0.25
    """Fraction of the total LLM context window allocated to memory/compiler blocks.
    Target range: 20-30%.  Default: 0.25."""

    token_budget_user_fraction: float = 0.55
    """Fraction of context for user input, tool results, and reasoning room.
    Target range: 50-60%.  Default: 0.55."""

    token_budget_reserve_fraction: float = 0.20
    """Reserve fraction for dynamic updates or long answers.
    Target range: 10-20%.  Default: 0.20."""

    token_budget_slot_priorities: List[str] = field(
        default_factory=lambda: [
            "task_context", "boot_context", "open_loops",
            "semantic_claims", "procedural", "episodic",
        ]
    )
    """Ordered list of InjectionSlot value strings defining memory slot priority.
    First entry = highest priority (allocated first, truncated last).
    Issue #374 — slot priority order."""

    # ------------------------------------------------------------------ #
    # Drift detection (M11 — DriftCalculator)
    # Thresholds that determine DriftCalculator actions:
    #   score < 0.1    → 'none'  (healthy)
    #   0.1 ≤ s < 0.3  → 'warn'  (log + monitor)
    #   0.3 ≤ s < 0.5  → 'restore' (auto-restore from golden copy)
    #   s ≥ 0.5        → 'alert' (restore + alert operator)
    # ------------------------------------------------------------------ #
    drift_check_threshold: float = 0.3
    """Drift score threshold that triggers auto-restore from golden copy."""

    drift_restore_threshold: float = 0.5
    """Drift score threshold that triggers operator alert (above restore)."""

    anti_compression_confidence: float = 0.9
    """Confidence threshold for anti-compression detection in cornerstones."""

    # --- Drift threshold aliases (read-only properties below) ---
    # The original field names (drift_check_threshold, drift_restore_threshold)
    # are confusingly swapped relative to the actions they trigger:
    #   drift_check_threshold  (0.3) → triggers RESTORE action
    #   drift_restore_threshold (0.5) → triggers ALERT action
    # The properties drift_restore_score and drift_alert_score provide
    # correctly named aliases. Prefer these in new code.

    # ------------------------------------------------------------------ #
    # Brain Graph (M10c — Watcher + Auto-Registration)
    # ------------------------------------------------------------------ #
    brain_graph_watch_dirs: List[str] = field(default_factory=lambda: ["docs"])
    brain_graph_include_patterns: List[str] = field(
        default_factory=lambda: ["*.md", "*.txt", "*.py"]
    )
    brain_graph_exclude_patterns: List[str] = field(
        default_factory=lambda: ["*.pyc", "__pycache__/*", "*.egg-info/*", ".git/*"]
    )
    brain_graph_debounce_seconds: float = 5.0

    # ------------------------------------------------------------------ #
    # Hotness scoring (#1343 — memory lifecycle)
    # ------------------------------------------------------------------ #
    hotness_half_life_days: int = 45
    """Half-life in days for hotness recency decay.  After this many days
    since last access, the recency component drops to 0.5."""

    hotness_cooling_threshold: float = 0.3
    """Claims with hotness below this are considered 'cooling'."""

    hotness_archive_threshold: float = 0.1
    """Claims with hotness below this are candidates for archival."""

    archival_sweep_enabled: bool = True
    """Enable the S4c archival sweep during the sleep pipeline.
    Set to False to disable automatic archival without changing thresholds."""

    hotness_max_claims_per_sweep: int = 200
    """Maximum number of claims to archive in a single sweep run.
    Acts as a safety cap against bulk-archival on first run."""

    # ------------------------------------------------------------------ #
    # Deprecation / lifecycle
    # ------------------------------------------------------------------ #
    deprecation_cooling_days: int = 14
    deprecation_archive_days: int = 90

    # ------------------------------------------------------------------ #
    # Session / confidence defaults (M13 — CortexPlugin lifecycle)
    # ------------------------------------------------------------------ #
    session_confidence_default: float = 0.5
    """Default confidence for newly extracted session claims."""

    promoted_confidence_default: float = 0.8
    """Confidence assigned to claims when promoted from session to long-term."""

    session_fatigue_nap_threshold: float = 0.7
    """Fatigue score at which the agent should take a short nap (compact context)."""

    session_fatigue_sleep_threshold: float = 0.9
    """Fatigue score at which the agent should sleep (full session end)."""

    # ------------------------------------------------------------------ #
    # Commitments / open loops
    # ------------------------------------------------------------------ #
    open_loop_stale_threshold: int = 5
    """Number of sleep/wake cycles after which an open loop is auto-resolved
    as stale. Higher values let loops survive longer before auto-cleanup."""

    # ------------------------------------------------------------------ #
    # Circadian / dream cycle
    # ------------------------------------------------------------------ #
    dream_cycle_hour_utc: int = 8
    dream_cycle_idle_timeout_sec: int = 3600
    dream_cycle_enabled: bool = True
    dream_cycle_timeout_sec: int = 600
    deep_dream_enabled: bool = False
    """Enable the Deep Dream phase (Gen1 Soul) after the 10-step consolidation.
    When True, an Eva-voiced philosophical exploration runs after M6c.
    Default: False — must be explicitly enabled. Issue #772."""

    auto_sleep_after_minutes: int = 30
    fatigue_interval_sec: int = 300
    """Seconds between background fatigue time-increment ticks (default 5 minutes)."""

    # ------------------------------------------------------------------ #
    # Cornerstones
    # ------------------------------------------------------------------ #
    cornerstone_auto_apply: bool = False
    max_cornerstone_count: int = 10
    cornerstone_drift_threshold: float = 0.05
    cornerstone_auto_apply_threshold: Optional[float] = None
    cornerstone_block_ordering: Optional[List[str]] = None
    """Custom category block ordering for cornerstone injection.

    When set to a list of category strings, cornerstones are grouped into
    named blocks in that order (see CornerstoneGuardian.format_injection_block).
    ``None`` (the default) uses the built-in DEFAULT_BLOCK_ORDER:
    identity → relational → behavioral → preferences → goals → episodic →
    decisions → other.

    Issue #712 / v1.3.0 epic (#711)."""
    """Confidence threshold for auto-applying evolution proposals (0.0–1.0).

    When set to a float, proposals with LLM confidence >= this threshold are
    automatically applied without operator review.  When None (the default),
    ALL proposals require manual review via apply_evolution().

    Recommended value if enabling: 0.95 — only very high-confidence, purely-
    additive changes are auto-applied.  Never set below 0.80 in production.
    """

    # ------------------------------------------------------------------ #
    # Retrieval (M8 — RetrievalPipeline)
    # ------------------------------------------------------------------ #
    retrieval_default_strategy: str = "hybrid"
    """Default retrieval strategy: 'hybrid' | 'semantic' | 'bm25'.
    Hybrid combines vector similarity (70%) with BM25 keyword match (30%)."""

    retrieval_max_tokens: int = 4000
    """Max tokens returned from a single retrieval call."""

    retrieval_vector_weight: float = 0.7
    """Weight for vector (semantic) similarity in hybrid scoring (0-1)."""

    retrieval_bm25_weight: float = 0.3
    """Weight for BM25 keyword match in hybrid scoring (0-1).
    Must satisfy: vector_weight + bm25_weight = 1.0."""

    retrieval_recency_weight: float = 0.1
    """Weight for recency bonus in hybrid scoring (0.0-1.0). (#271, #847)

    When > 0, recently created claims receive a score boost proportional to
    this weight using an exponential decay with half-life of 30 days.
    Default 0.1 — validated range is 0.1-0.2 (see research #842).
    Set to 0.0 to disable recency bonus entirely.

    Override via env var: CORTEX_RETRIEVAL_RECENCY_WEIGHT=0.1"""

    retrieval_recency_half_life_days: float = 30.0
    """Half-life in days for the recency exponential decay (#847).

    At this age, a claim's recency bonus is halved. Default 30 days.
    Override via env var: CORTEX_RETRIEVAL_RECENCY_HALF_LIFE_DAYS=30.0"""

    retrieval_explicitness_bonus: float = 0.05
    """Score bonus for claims with explicitness='explicit' (#847).

    Applied additively to the base retrieval score. Default 0.05.
    Override via env var: CORTEX_RETRIEVAL_EXPLICITNESS_BONUS=0.05"""

    retrieval_stability_bonus: float = 0.03
    """Score bonus for claims with stability='stable' (#847).

    Applied additively to the base retrieval score. Default 0.03.
    Override via env var: CORTEX_RETRIEVAL_STABILITY_BONUS=0.03"""

    retrieval_temporal_boost_weight: float = 0.15
    """Weight for temporal proximity boost (issue #513). When > 0, claims with
    event_date near a temporal hint detected in the query get a score bonus."""

    retrieval_rrf_k: int = 60
    """Smoothing constant for Reciprocal Rank Fusion (RRF).

    Controls how much weight top-ranked items get vs lower-ranked ones.
    k=60 is the standard value from the original RRF paper (Cormack 2009).
    Higher k = more uniform weighting across ranks."""

    retrieval_max_chained_hops: int = 2
    """Maximum number of chained retrieval hops for multi-hop queries (#1279).

    When > 0, enables entity-based chained retrieval: initial results are
    scanned for entities, which are used to formulate follow-up queries.
    Set to 0 to disable chained retrieval entirely."""

    retrieval_rerank_enabled: bool = True
    """Enable Voyage rerank step after initial vector/BM25 retrieval.

    When True, the pipeline fetches retrieval_rerank_initial_candidates results,
    passes them through Voyage rerank API, and returns the top retrieval_rerank_top_k.
    Requires VOYAGE_API_KEY env var. Falls back gracefully if the API call fails.
    """

    retrieval_rerank_model: str = "rerank-2.5-lite"
    """Voyage rerank model to use. Options: 'rerank-2.5-lite' (fast/cheap),
    'rerank-2.5' (higher quality). Both are in the 200M free token tier."""

    retrieval_rerank_top_k: int = 5
    """Number of final results to return after reranking.
    The rerank pass selects the top_k most relevant items from initial_candidates."""

    retrieval_rerank_initial_candidates: int = 30
    """Number of initial candidates fetched before the rerank step.
    Higher values give the reranker more to work with at the cost of first-pass latency."""

    retrieval_trace_enabled: bool = False
    """Enable structured JSON retrieval trace logging (#1347).

    When True, each retrieve() call emits a structured JSON log entry to the
    ``cortex.retrieval.trace`` logger containing: query, stage candidate counts,
    top scores, timing per stage, final selected items, and category distribution.
    Disabled by default to avoid log volume in production; enable for debugging
    retrieval quality or observability pipelines."""

    retrieval_trace_log_level: str = "DEBUG"
    """Log level for retrieval trace entries (#1347).

    Controls which Python logging level the trace JSON is emitted at.
    Valid values: DEBUG, INFO, WARNING, ERROR. Default: DEBUG."""

    retrieval_confidence_threshold: float = 0.3
    """Minimum similarity score for retrieved claims to generate an answer (#1275).

    When ALL top-k retrieved claims have similarity scores below this threshold,
    the system returns a refusal response instead of generating a potentially
    hallucinated answer. Set to 0.0 to disable refusal."""

    retrieval_status_penalties: Optional[Dict[str, float]] = None
    """Score penalty multipliers for non-active claim statuses (#574).
    Maps status names to multiplier factors (0.0-1.0). When None, falls back
    to RetrievalPipeline._DEFAULT_STATUS_PENALTIES."""
    # ------------------------------------------------------------------ #
    # Extraction misc
    # ------------------------------------------------------------------ #
    extraction_prompt_version: str = "v2"
    """Extraction prompt version ('v1' or 'v2'). v2 adds session date injection,
    enhanced temporal resolution, and stronger episodic event extraction."""

    extraction_mode: str = "unified"
    """Extraction mode for claim extraction (#321).

    'unified' (default) — single LLM pass over all messages regardless of role.
                          Preserves current behavior exactly.
    'split'             — separate LLM passes: one for user messages (preferences,
                          facts, personal history) and one for agent/assistant messages
                          (capabilities, behaviors, self-knowledge). Extracted claims
                          are tagged with source_role='user' or source_role='agent'.
    """

    extraction_enabled: bool = True
    """Master toggle for claim extraction. When False, remember() logs the
    episode event but skips LLM extraction entirely. Useful for import-only
    or evidence-logging-only deployments."""

    extraction_temporal_markers: bool = True
    """Enable temporal marker extraction during claim extraction (#1276).

    When True, the extraction pipeline extracts temporal markers (e.g. 'last week',
    'March 2024') and temporal class (current/past/future/permanent) from claims.
    These are stored in semantic_claim.temporal_marker and semantic_claim.temporal
    and used by the retrieval pipeline for time-aware scoring.

    Default: True. Set to False only if temporal extraction causes issues."""

    coding_noise_filter: bool = True

    extraction_block_internal: bool = True
    """Block memory extraction on internal/system content (issue #866).

    When True (default), messages containing engine-injected blocks such as
    ``<relevant-memories>``, subagent completion reports, runtime context,
    heartbeats, and child-result wrappers are silently skipped before LLM
    extraction runs.  Prevents garbage claims like "Eva ran a tool" or
    "Session started" from polluting the memory store.

    Set to False to disable the filter (e.g. during debugging or eval runs
    where you explicitly want to inspect what the engine would extract from
    internal content)."""

    extract_assistant_turns: bool = True
    """Enable extraction from assistant/AI turns in addition to user turns (#1277).

    When False, remember() only processes user messages for claim extraction.
    When True, assistant messages are also processed, enabling the system to learn
    from its own responses (useful for benchmarks like Long-MT-Bench+ where the
    conversation history includes both sides).

    Default: True."""

    extraction_batch_size: int = 5
    """Max messages per LLM extraction call (batch size).
    Larger batches = fewer calls but higher per-call latency.
    Default 5 balances cost and latency for typical conversations."""

    # ------------------------------------------------------------------ #
    # Entity Resolution (M3 — #590)
    # ------------------------------------------------------------------ #
    entity_resolution_candidate_k: int = 10
    """Number of nearest-neighbour candidates per entity in suggest_merges().

    When embedding-based candidate selection is enabled, each entity is
    compared only against its top-K most similar neighbours (by embedding
    cosine similarity).  This reduces the comparison count from O(n²) to
    O(n×K).

    Larger values → more recall (fewer missed duplicates) but more
    fuzzy-string comparisons.  Default 10 is a good balance.
    Set to 0 to disable candidate selection and fall back to full O(n²).
    Configurable via [cortex.entity_resolution] candidate_k = 10.
    """

    entity_resolution_batch_size: int = 100
    """Batch size for entity loading in suggest_merges().

    Entities are processed in pages of this size to avoid loading all
    aliases into memory at once.  Default 100 balances memory usage and
    query round-trips.
    Configurable via [cortex.entity_resolution] batch_size = 100.
    """

    extraction_custom_instructions: str = ""
    """Optional deployment-specific extraction rules appended to the system prompt.

    When non-empty, this string is appended after EXTRACTION_SYSTEM_PROMPT before
    the LLM call.  Use it to add project-specific memory rules without editing
    source code.

    Example (in user config TOML):
        [cortex.extraction]
        custom_instructions = \"\"\"
        ADDITIONAL RULES:
        - Always tag mentions of the project 'Cortex' under decisions category.
        - Never extract salary or compensation details.
        \"\"\"
    """

    max_claims_per_extraction: int = 25
    """Hard cap on extracted claims per remember() call.

    Applies after all extraction batches are aggregated. Set to 0 to disable.
    """

    max_entities_per_extraction: int = 100
    """Hard cap on total entity mentions kept per extraction result.

    Excess entities are trimmed from later claims first. Set to 0 to disable.
    """

    known_subjects: List[str] = field(default_factory=lambda: [
        "user", "agent", "assistant", "Eva", "eva",
    ])
    """Known valid subjects for extraction claims.

    Claims with subjects not in this list (case-insensitive) are filtered out
    to prevent eval-data leaks and third-party contamination.
    Set to an empty list to disable subject filtering."""

    max_concurrent_extractions: int = 4
    """Maximum concurrent extraction LLM calls per plugin instance.

    Implemented via an async semaphore in CortexPlugin.remember().
    """

    extraction_zero_claim_log_level: str = "INFO"
    """Log level used when extraction produces zero claims for a valid non-empty input.

    Controls how visibly the pipeline reports zero-claim outcomes.
    Valid values: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL".

    Default "INFO" makes zero-claim events visible in normal operation.
    Use "DEBUG" to suppress them in production if they are too noisy.
    Use "WARNING" or "ERROR" to escalate them.

    Configurable in [cortex.extraction] zero_claim_log_level = "INFO".
    """

    extraction_zero_claim_log_preview: bool = False
    """Whether to include a content preview in zero-claim log messages.

    When False (default), logs message count and content length instead of
    raw content — preventing PII leakage at INFO level.
    When True, includes a 120-char preview of the first message content.

    Configurable in [cortex.extraction] zero_claim_log_preview = false.
    """

    # ------------------------------------------------------------------ #
    # LLM call budgets (M13 — cost guard rails)
    # Prevent runaway sessions from exceeding the $8/month target.
    # ------------------------------------------------------------------ #
    max_llm_calls_per_session: int = 1000
    """Hard limit on LLM calls per session (extraction + reconciliation + retrieval).
    When exhausted the plugin returns errors instead of calling the LLM.
    Resets on wake(). Set to 0 to disable."""

    max_extraction_calls_per_batch: int = 25
    """Max number of claims passed from extraction → reconciliation per remember() call.
    Excess claims are silently dropped (cheapest ones first)."""

    # ------------------------------------------------------------------ #
    # Authentication (#542)
    # ------------------------------------------------------------------ #
    auth_jwt_secret: str = ""
    """Supabase JWT secret for HS256 token validation.

    Set via SUPABASE_JWT_SECRET environment variable (preferred) or in
    [cortex.auth] jwt_secret in your TOML config.  When set, the server
    validates Bearer tokens as Supabase JWTs and extracts owner_id from
    the sub claim.
    """

    auth_api_key: str = ""
    """Legacy API key for self-host and power-user access.

    Set via CORTEX_API_KEY environment variable or [cortex.auth] api_key.
    When jwt_secret is also set, JWT is checked first and this key is the
    fallback.  When neither is set, the server operates in open-access mode.
    """

    auth_require_auth: bool = False
    """When True, unauthenticated (open-access) requests are rejected with 401.

    Default False for local dev.  Set to True in hosted / production deployments.
    """

    auth_public_endpoints: list = None  # type: ignore[assignment]
    """Paths that bypass authentication regardless of require_auth.

    Defaults to ['/api/v1/health', '/api/v1/metrics', '/docs', '/openapi.json', '/redoc'].
    """

    def __post_init__(self) -> None:
        """Apply default mutable values that can't be set as dataclass defaults."""
        if self.auth_public_endpoints is None:
            self.auth_public_endpoints = [
                "/api/v1/health",
                "/api/v1/metrics",
                "/docs",
                "/openapi.json",
                "/redoc",
            ]


    # ------------------------------------------------------------------ #
    # Rate limiting (#71)
    # ------------------------------------------------------------------ #
    rate_limit_rpm: int = 60
    """HTTP API sustained request rate limit (requests per minute, per identity).
    Set to 0 to disable rate limiting entirely."""

    rate_limit_burst: int = 10
    """HTTP API burst allowance (max tokens above the sustained rate).
    Allows short bursts before the per-minute limit kicks in."""

    # ------------------------------------------------------------------ #
    # Per-provider LLM rate limits (extraction pipeline)
    # ------------------------------------------------------------------ #
    provider_rate_limits: Dict[str, Any] = field(default_factory=dict)
    """Per-provider rate limit config for the extraction pipeline.

    Maps provider name → dict with keys:
      - ``requests_per_minute`` (int, default 60)
      - ``tokens_per_minute``   (int, default 90_000)
      - ``burst_requests``      (int, default = requests_per_minute)
      - ``burst_tokens``        (int, default = tokens_per_minute)

    Example (cortex.toml)::

        [providers.rate_limits.openai]
        requests_per_minute = 60
        tokens_per_minute = 90_000

        [providers.rate_limits.anthropic]
        requests_per_minute = 50
        tokens_per_minute = 100_000

    Set to ``{}`` to use built-in defaults (60 RPM / 90k TPM) for all
    providers.  Set ``requests_per_minute = 0`` for a specific provider to
    disable request-count limiting (token limiting still applies).
    """

    # ------------------------------------------------------------------ #
    # Remember body size limit (Issue #486)
    # ------------------------------------------------------------------ #
    max_remember_body_bytes: int = 1_000_000
    """Hard cap on serialised JSON size of /remember request bodies (bytes).

    Prevents arbitrarily large payloads from reaching the extraction pipeline.
    Returns HTTP 413 when exceeded.  Set to 0 to disable (not recommended).
    Override in your config TOML under [cortex.api] max_remember_body_bytes.
    """

    # ------------------------------------------------------------------ #
    # Pagination (L2 audit fix)
    # ------------------------------------------------------------------ #
    max_page_size: int = 200
    """Hard cap on per_page for all paginated API and storage queries.

    The HTTP layer enforces this at the route level; the storage layer
    (claim_queries.MAX_PAGE_SIZE) clamps it independently so direct storage
    callers cannot bypass the limit.  Override in your config TOML under
    [cortex.pagination] max_page_size.
    """

    # ------------------------------------------------------------------ #
    # Log retention / TTL (L3 audit fix)
    # ------------------------------------------------------------------ #
    log_retention_days: int = 90
    """Days to retain event_log and request_log rows.

    Rows older than this threshold are deleted by ``prune_old_logs()``.
    Wire into the circadian cycle or call via the admin prune endpoint.
    Set to 0 to disable automatic pruning (retain forever).
    """

    # ------------------------------------------------------------------ #
    # Working Memory Cache (#371)
    # ------------------------------------------------------------------ #
    working_memory_enabled: bool = True
    """Enable the in-memory working memory cache. When False, all WorkingMemoryCache
    operations are no-ops and retrieval falls through directly to DB queries."""

    working_memory_max_items: int = 100
    """Per-session item cap. When a session fills up, the least-recently-used
    entry is evicted. Range: 1–10000."""

    working_memory_default_ttl_seconds: int = 1800
    """Default time-to-live for working memory entries in seconds.
    0 = no TTL (entries live until session end). Default: 1800s (30 min)."""

    # ------------------------------------------------------------------ #
    # Embedding Cache (v1.2.1)
    # ------------------------------------------------------------------ #
    embedding_cache_enabled: bool = True
    """Enable in-memory LRU+TTL cache for embedding vectors.

    When True, repeated queries that normalise to the same text hit the cache
    instead of calling the Voyage API (~150ms saved per hit).
    When False, every retrieve() call embeds from scratch."""

    embedding_cache_max_size: int = 1000
    """Maximum number of embedding vectors to hold in memory.

    At 1024 dimensions (float32), each entry is ~4KB.
    1000 entries ≈ 4MB RAM. Least-recently-used entries are evicted when full.
    Range: 1–100000."""

    embedding_cache_ttl_seconds: int = 3600
    """Time-to-live for each cached embedding in seconds.

    Entries older than this are automatically expired.
    Default: 3600s (1 hour) — covers a full conversation session.
    Set to 0 to use maxsize-only LRU eviction (no TTL). Note: cachetools
    requires ttl > 0; use a very large value to approximate no-TTL behaviour."""

    # ------------------------------------------------------------------ #
    # DB Pool / per-user connection pool (#551)
    # ------------------------------------------------------------------ #
    db_pool_max_open: int = 100
    """Maximum number of simultaneously open per-user SQLite connections.

    DBManager evicts the least-recently-used connection when this limit is
    exceeded. Range: 1–10000. Default: 100."""

    db_pool_idle_timeout_seconds: float = 300.0
    """Seconds after which an idle per-user connection is lazily evicted.

    Set to 0 to disable age-based eviction (LRU-only). Default: 300s (5 min)."""

    db_pool_data_dir: str = "/data"
    """Base directory for per-user SQLite files: ``{data_dir}/{owner_id}.db``.

    Must be an absolute path in production. Override with the CORTEX_DB_POOL_DATA_DIR
    env var or in your config TOML under [cortex.db_pool]."""

    # ------------------------------------------------------------------ #
    # Model routing (#375 — three-tier model selection)
    # ------------------------------------------------------------------ #
    model_routing_enabled: bool = False
    """Enable three-tier model routing for LLM calls.

    When True, each LLM callsite uses the operation-to-tier mapping to select
    the appropriate model (fast/main/reasoning). When False (default), all
    calls use the existing single-provider behavior — zero behavior change.
    """

    model_routing_fast: str = ""
    """Model name/ID for the FAST tier (cheap, low-latency).

    Used for: extraction, classification, routing, lightweight summarization.
    Example: 'gpt-4.1-nano-2025-04-14'
    """

    model_routing_main: str = ""
    """Model name/ID for the MAIN tier (higher quality).

    Used for: consolidation, handoff building, deep summarization.
    Example: 'gpt-4.1-mini-2025-04-14'
    """

    model_routing_reasoning: str = ""
    """Model name/ID for the REASONING tier (best quality, expensive).

    Used for: contradiction analysis, reconciliation, entity resolution.
    Example: 'gpt-4.1-2025-04-14'
    """

    model_routing_operation_map: dict = field(default_factory=dict)
    """Custom operation-to-tier overrides.

    Maps operation name strings to tier name strings ('fast', 'main', 'reasoning').
    Overrides the hardcoded defaults in cortex.llm.router.DEFAULT_OPERATION_MAP.
    Example: {"extraction": "main", "reconciliation": "fast"}
    """

    # ------------------------------------------------------------------ #
    # Metrics (#368 — observability)
    # ------------------------------------------------------------------ #
    metrics_enabled: bool = True
    """Enable metrics collection. When False, all MetricsCollector operations
    are zero-cost no-ops."""

    metrics_export_format: str = "json"
    """Export format for metrics endpoint: 'json' or 'prometheus'."""

    # ------------------------------------------------------------------ #
    # Quality Gates (#368 — production readiness checks)
    # ------------------------------------------------------------------ #
    quality_gates_enabled: bool = True
    """Enable quality gate evaluation in health checks. When False, gates
    are skipped entirely in health()."""

    # ------------------------------------------------------------------ #
    # Feature Flags (#368 — gradual rollout)
    # ------------------------------------------------------------------ #
    feature_flags_enabled: bool = True
    """Enable feature flag gating. When False, all features are considered
    enabled (no gating). When True, flags control feature activation."""

    # ------------------------------------------------------------------ #
    # Circuit breaker (#347)
    # ------------------------------------------------------------------ #
    circuit_breaker_enabled: bool = True
    """Enable per-provider circuit breaker for quota/rate-limit resilience.

    When True, repeated 429/quota failures open the breaker and block
    write operations (remember/extract) while reads (retrieval) continue
    via non-LLM paths. Set to False to disable entirely (not recommended
    for production).
    """

    circuit_breaker_threshold: int = 5
    """Consecutive quota/rate-limit failures before opening the circuit breaker.

    Only QUOTA_EXHAUSTED and RATE_LIMITED failures count. Transient errors
    and model errors do not contribute to this count (handled by retry logic).
    Default: 5. Lower values trip the breaker sooner; higher values allow
    more retries before blocking writes.
    """

    circuit_breaker_cooldown_sec: int = 300
    """Seconds after opening before the circuit breaker auto-transitions to
    HALF_OPEN (probe state). In HALF_OPEN, one write attempt is allowed to
    test provider recovery. Default: 300s (5 minutes).
    """

    # ------------------------------------------------------------------ #
    # Webhook SSRF protection (#435)
    # ------------------------------------------------------------------ #
    webhook_allow_private_urls: bool = False
    """Allow webhooks to target private/internal URLs (development only)."""

    # ------------------------------------------------------------------ #
    # Aegis — Sentry integration (#829, #1071)
    # ------------------------------------------------------------------ #
    aegis_sentry_secret: str = ""
    """HMAC-SHA256 secret for verifying incoming Sentry webhook signatures.

    Set via AEGIS_SENTRY_SECRET environment variable or [aegis] sentry_secret
    in config TOML.  An empty value disables signature verification (dev mode).
    """

    # ------------------------------------------------------------------ #
    # Re-Retrieval (#372 — mid-turn supplemental retrieval)
    # ------------------------------------------------------------------ #
    re_retrieval_enabled: bool = True
    """Enable mid-turn re-retrieval triggered by tool results, topic shifts,
    user corrections, or explicit agent requests. When False, on_tool_result()
    is a no-op."""

    re_retrieval_max_per_turn: int = 2
    """Maximum number of supplemental re-retrievals allowed per conversation
    turn. Prevents runaway retrieval loops. Range: 1–10."""

    re_retrieval_topic_shift_threshold: float = 0.7
    """Keyword divergence score (0.0–1.0) above which a topic shift trigger
    fires. Higher = requires more divergence to trigger. Default: 0.7."""

    re_retrieval_min_budget_tokens: int = 500
    """Minimum remaining token budget required before a re-retrieval is
    attempted. If budget_tokens < this, the re-retrieval is skipped."""

    # ------------------------------------------------------------------ #
    # Context Compiler (#363)
    # ------------------------------------------------------------------ #
    context_compiler_enabled: bool = True
    """Enable the context compiler for assembling retrieval results into
    injection-ready bundles. When False, retrieval results bypass the
    compiler and are injected without bundling."""

    context_compiler_max_bundles: int = 7
    """Maximum number of bundle types to include in compiled context.
    Range: 1–7. Default: 7 (all bundle types)."""

    context_compiler_budget_overflow_strategy: str = "compress_then_drop"
    """Strategy when compiled bundles exceed the token budget.
    'compress_then_drop': compress lowest-priority bundles first,
    then drop entirely if still over budget."""

    # ------------------------------------------------------------------ #
    # Boot Loader (#361)
    # ------------------------------------------------------------------ #
    boot_enabled: bool = True
    """Enable the session boot loader. When True, a deterministic boot sequence
    is built at session start to provide continuity context."""

    boot_max_recent_sessions: int = 3
    """Maximum number of recent session summaries to load during boot.
    Range: 1–10. Default: 3."""

    boot_min_stability_threshold: float = 0.7
    """Minimum confidence/stability score for loading preferences during boot.
    Claims with confidence below this threshold are excluded.
    Range: 0.0–1.0. Default: 0.7."""

    boot_budget_fraction: float = 0.15
    """Fraction of the total context window reserved for boot context.
    Range: 0.01–0.50. Default: 0.15 (15% of context budget)."""

    # ------------------------------------------------------------------ #
    # Retrieval Policy Engine (#362)
    # ------------------------------------------------------------------ #
    retrieval_policy_enabled: bool = True
    """Enable task-mode driven retrieval policy routing. When True, queries
    are classified into task modes and retrieval channels are selected based
    on the mode's policy. When False, existing retrieval behavior is unchanged."""

    retrieval_policy_confidence_threshold: float = 0.3
    """Minimum classification confidence required to use a mode-specific policy.
    Below this threshold, the fallback (balanced) policy is used instead.
    Range: 0.0–1.0. Default: 0.3."""

    retrieval_policy_default_mode: str = "recall_history"
    """Default task mode when detection fails entirely (no signals matched).
    Must be a valid TaskMode value. Default: 'recall_history'."""

    # ------------------------------------------------------------------ #
    # Promotion scoring (#376)
    # ------------------------------------------------------------------ #
    promotion_weight_evidence_strength: float = 0.30
    """Weight for evidence_strength component in promotion scoring."""

    promotion_weight_cross_session_frequency: float = 0.25
    """Weight for cross_session_frequency component in promotion scoring."""

    promotion_weight_user_explicitness: float = 0.20
    """Weight for user_explicitness component in promotion scoring."""

    promotion_weight_retrieval_utility: float = 0.10
    """Weight for retrieval_utility component in promotion scoring."""

    promotion_weight_temporal_stability: float = 0.10
    """Weight for temporal_stability component in promotion scoring."""

    promotion_weight_contradiction_absence: float = 0.05
    """Weight for contradiction_absence component in promotion scoring."""

    promotion_threshold: float = 0.6
    """Score threshold for promoting a claim from session → long-term (0.0–1.0)."""

    promotion_min_sessions: int = 1
    """Minimum distinct sessions referencing a claim before it is eligible for promotion."""

    # ------------------------------------------------------------------ #
    # Context Window Calculator (#329)
    # ------------------------------------------------------------------ #
    context_calculator_enabled: bool = True
    """Enable the context window calculator for real-time token tracking.
    When False, ContextWindowCalculator is still usable but no metrics are emitted."""

    context_calculator_reserve_fraction: float = 0.15
    """Fraction of the total context window held back for model output.
    Range: 0.0–0.5. Default: 0.15 (15%)."""

    context_calculator_allocation_strategy: str = "proportional"
    """Budget allocation strategy name. Only 'proportional' is implemented;
    it distributes (total - reserve) across categories using category_weights."""

    context_calculator_category_weights: dict = field(default_factory=dict)
    """Per-category allocation weights (must sum to ~1.0 minus reserve_fraction).

    When empty (default), the built-in DEFAULT_CATEGORY_WEIGHTS are used:
        boot: 0.10, system: 0.10, user: 0.25, episodic: 0.15, semantic: 0.15,
        working_memory: 0.05, procedural: 0.05

    Override in cortex.toml under [cortex.context_calculator.category_weights].
    """

    # ------------------------------------------------------------------ #
    # Deployment tier
    # ------------------------------------------------------------------ #
    tier: CortexTier = CortexTier.LITE
    """Deployment tier (lite or full). Controls feature availability.

    Set to 'full' in your config TOML to enable advanced features.
    Defaults to 'lite' (entry-level, conservative feature set).
    """

    # ------------------------------------------------------------------ #
    # Debug Logging (debug/observability layer)
    # ------------------------------------------------------------------ #
    debug_logging_enabled: bool = False
    """Enable debug request/response and pipeline event logging.
    When False, the debug middleware is a zero-cost no-op passthrough."""

    debug_log_max_entries: int = 10000
    """Maximum entries in the in-memory debug ring buffer.
    Oldest entries are evicted when full. Restarts clear the buffer."""

    # ------------------------------------------------------------------ #
    # Shared Stats DB (cross-user usage metering + billing analytics)
    # ------------------------------------------------------------------ #
    stats_enabled: bool = True
    """Enable the shared stats.db for cross-user usage metering and Stripe billing."""

    stats_db_path: str = "/data/stats.db"
    """Path to the shared stats SQLite database. Separate from per-user memory DBs."""

    stats_prune_after_days: int = 30
    """Delete request_log entries older than this many days during nightly prune."""

    # ------------------------------------------------------------------ #
    # Owner / multitenancy
    # ------------------------------------------------------------------ #
    owner_id: str = "default"

    # ------------------------------------------------------------------ #
    # Staging / namespace isolation (#765)
    # ------------------------------------------------------------------ #
    staging_mode: bool = False
    """When True, every write request is logged with a warning and responses
    include an ``X-Staging: true`` header.  Intended for staging / eval
    deployments so callers can detect they are NOT talking to production.

    Zero production impact when False (default)."""

    reject_test_namespace_writes: bool = False
    """When True, write requests (POST/PUT/PATCH/DELETE) whose ``owner_id``
    starts with a reserved test prefix are rejected with HTTP 403.

    When enabled, requests with missing or undetermined owner_id are also
    rejected (HTTP 400) to prevent silent namespace bypasses.

    Use on production to prevent test/eval agents from contaminating real
    user data (c.f. the 699-memory wipe incident, 2026-03-13).

    Zero production impact when False (default)."""

    namespace_guard_test_prefixes: list[str] | None = None
    """Optional list of owner_id prefixes treated as test namespaces.

    When ``None`` (default), falls back to the hardcoded defaults:
    ``["eval-", "test-", "dev-"]``.

    Example: ``["eval-", "test-", "dev-", "staging-", "qa-"]``"""

    # ------------------------------------------------------------------ #
    # Drift threshold aliases (properly named)
    # ------------------------------------------------------------------ #

    @property
    def drift_restore_score(self) -> float:
        """Drift score at or above which auto-restore is triggered.

        Alias for drift_check_threshold (0.3 by default). The original name
        is misleading — "check" doesn't convey that a restore happens.
        """
        return self.drift_check_threshold

    @property
    def drift_alert_score(self) -> float:
        """Drift score at or above which an operator alert is triggered.

        Alias for drift_restore_threshold (0.5 by default). The original name
        is misleading — "restore" doesn't convey that an alert happens.
        """
        return self.drift_restore_threshold

    # ------------------------------------------------------------------ #
    # Config Observatory (#1074)
    # ------------------------------------------------------------------ #

    observatory_nodes: List[str] = field(default_factory=list)
    """List of evaOS node base URLs to query in Config Observatory drift reports.

    Each entry is a JSON-encoded dict with keys: ``url``, ``name``, ``api_key``.
    Example (in TOML)::

        [cortex.observatory]
        nodes = [
            '{"url": "https://cortex.fly.dev", "name": "cortex-prod", "api_key": "sk-..."}',
            '{"url": "https://kira.local:8080", "name": "kira", "api_key": "sk-..."}',
        ]

    In practice this is usually set via environment variable
    ``CORTEX_OBSERVATORY_NODES`` as a JSON array string.
    The ``GET /api/v1/admin/fleet/config-drift`` admin route reads this list
    at request time via ``NodeConfig`` objects parsed from each entry.
    """

    observatory_fetch_timeout_s: float = 10.0
    """HTTP timeout (seconds) for each node fetch in Config Observatory.

    Applies to each individual node request when building a drift report.
    Nodes that exceed this timeout are included in the report with
    ``status="timeout"`` rather than causing the entire request to fail.
    """

    # ------------------------------------------------------------------ #
    # Helpers
    # ------------------------------------------------------------------ #

    @classmethod
    def from_toml(
        cls,
        path: Optional[str | Path] = None,
        *,
        overlay: Optional[dict[str, Any]] = None,
    ) -> "CortexConfig":
        """
        Build a CortexConfig from TOML file(s).

        Load order (later wins):
          1. Package defaults  (cortex/config/defaults.toml)
          2. ``path`` argument (user config, if provided)
          3. ``overlay`` dict  (in-process overrides, e.g. CLI flags)
        """
        # Late import to avoid circular: validation imports model at TYPE_CHECKING time
        from cortex.config.validation import validate_config as _validate_config  # noqa: F811

        if tomllib is None:
            raise ImportError(
                "tomllib not available. "
                "Install 'tomli' (`pip install tomli`) for Python < 3.11."
            )

        raw: dict[str, Any] = {}

        # 1. Package defaults
        if _DEFAULTS_PATH.exists():
            with open(_DEFAULTS_PATH, "rb") as fh:
                raw = tomllib.load(fh)

        # 2. User-supplied config
        if path is not None:
            user_path = Path(path)
            if user_path.exists():
                with open(user_path, "rb") as fh:
                    _deep_merge(raw, tomllib.load(fh))

        # 3. In-process overlay
        if overlay:
            _deep_merge(raw, overlay)

        config = cls._from_raw(raw)

        # 4. Environment variable overrides (CORTEX_<FIELD_NAME>)
        #    Applied last so env vars take highest precedence after overlay.
        env_overrides = cls._env_overrides()
        if env_overrides:
            for field_name, value in env_overrides.items():
                object.__setattr__(config, field_name, value)

        return config

    @classmethod
    def _from_raw(cls, raw: dict[str, Any]) -> "CortexConfig":
        """Flatten nested TOML dict into CortexConfig field values."""
        cortex = raw.get("cortex", {})
        storage = cortex.get("storage", {})
        embeddings = cortex.get("embeddings", {})
        llm = cortex.get("llm", {})
        extraction = cortex.get("extraction", {})
        reconciliation = cortex.get("reconciliation", {})
        token_budgets = cortex.get("token_budgets", {})
        drift = cortex.get("drift", {})
        brain_graph = cortex.get("brain_graph", {})
        cornerstones = cortex.get("cornerstones", {})
        confidence = cortex.get("confidence", {})
        fatigue = cortex.get("fatigue", {})
        circadian = cortex.get("circadian", {})
        retrieval = cortex.get("retrieval", {})
        deprecation = cortex.get("deprecation", {})
        budgets = cortex.get("budgets", {})

        kwargs: dict[str, Any] = {}

        # Top-level cortex keys
        if "version" in cortex:
            pass  # not stored in config dataclass
        if "storage_backend" in cortex:
            kwargs["storage_backend"] = cortex["storage_backend"]
        if "owner_id" in cortex:
            kwargs["owner_id"] = cortex["owner_id"]
        if "tier" in cortex:
            kwargs["tier"] = CortexTier(cortex["tier"])

        # Storage section
        kwargs.update({
            "storage_db_path": storage.get("db_path", "cortex.db"),
            "storage_enable_wal": storage.get("enable_wal", True),
            "storage_busy_timeout_ms": storage.get("busy_timeout_ms", 5000),
        })

        # Embeddings section
        if "model" in embeddings:
            kwargs["embedding_model"] = embeddings["model"]
        if "dim" in embeddings:
            kwargs["embedding_dim"] = embeddings["dim"]

        # LLM provider cascade (legacy — deprecated in v1.2.1)
        if "provider_priority" in llm:
            priority = llm["provider_priority"]
            if isinstance(priority, list) and all(isinstance(p, str) for p in priority):
                kwargs["llm_provider_priority"] = priority

        # Provider architecture (v1.2.1)
        providers = cortex.get("providers", {})

        # Backward compatibility: if old [cortex.llm] section has active user config
        # AND the new [cortex.providers] section is absent, log deprecation warning.
        _has_new_providers = bool(providers.get("mode") or providers.get("llm") or providers.get("embedding"))
        _has_old_llm_config = llm and any(k in llm for k in ("venice",))
        if _has_old_llm_config and not _has_new_providers:
            import warnings
            warnings.warn(
                "[cortex.llm] config section is deprecated in v1.2.1. "
                "Use [cortex.providers] instead. Old settings will be mapped "
                "automatically but will be removed in v1.3.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        if "mode" in providers:
            kwargs["provider_mode"] = str(providers["mode"])

        providers_llm = providers.get("llm", {})
        if "provider" in providers_llm:
            kwargs["provider_llm_provider"] = str(providers_llm["provider"])
        if "model" in providers_llm:
            kwargs["provider_llm_model"] = str(providers_llm["model"])
        if "api_key" in providers_llm:
            kwargs["provider_llm_api_key"] = str(providers_llm["api_key"])
        if "base_url" in providers_llm:
            kwargs["provider_llm_base_url"] = str(providers_llm["base_url"])

        # Per-operation provider overrides (Issue #1247)
        # [cortex.providers.extraction], [cortex.providers.reconciliation], etc.
        for op_name in ("extraction", "reconciliation", "dialectic"):
            op_section = providers.get(op_name, {})
            if "base_url" in op_section:
                kwargs[f"provider_{op_name}_base_url"] = str(op_section["base_url"])
            if "api_key" in op_section:
                kwargs[f"provider_{op_name}_api_key"] = str(op_section["api_key"])

        providers_embedding = providers.get("embedding", {})
        if "provider" in providers_embedding:
            kwargs["provider_embedding_provider"] = str(providers_embedding["provider"])
        if "model" in providers_embedding:
            kwargs["provider_embedding_model"] = str(providers_embedding["model"])
        if "api_key" in providers_embedding:
            kwargs["provider_embedding_api_key"] = str(providers_embedding["api_key"])
        if "base_url" in providers_embedding:
            kwargs["provider_embedding_base_url"] = str(providers_embedding["base_url"])
        if "dim" in providers_embedding:
            kwargs["provider_embedding_dim"] = int(providers_embedding["dim"])

        providers_usage = providers.get("usage", {})
        if "enabled" in providers_usage:
            kwargs["provider_usage_enabled"] = bool(providers_usage["enabled"])
        if "tier" in providers_usage:
            kwargs["provider_usage_tier"] = str(providers_usage["tier"])
        if "monthly_token_cap" in providers_usage:
            kwargs["provider_usage_monthly_token_cap"] = int(providers_usage["monthly_token_cap"])
        if "monthly_embedding_cap" in providers_usage:
            kwargs["provider_usage_monthly_embedding_cap"] = int(providers_usage["monthly_embedding_cap"])

        # Provider fallback chain (v1.2.1+)
        providers_fallback = providers.get("fallback", {})
        if "chain" in providers_fallback:
            chain = providers_fallback["chain"]
            if isinstance(chain, list) and all(isinstance(p, str) for p in chain):
                kwargs["provider_fallback_chain"] = chain
        if "cooldown_s" in providers_fallback:
            kwargs["circuit_breaker_cooldown_s"] = int(providers_fallback["cooldown_s"])

        # Extraction
        if "enabled" in extraction:
            kwargs["extraction_enabled"] = bool(extraction["enabled"])
        if "model" in extraction:
            kwargs["extraction_model"] = extraction["model"]
        if "coding_noise_filter" in extraction:
            kwargs["coding_noise_filter"] = extraction["coding_noise_filter"]
        if "block_internal" in extraction:
            kwargs["extraction_block_internal"] = bool(extraction["block_internal"])
        if "batch_size" in extraction:
            kwargs["extraction_batch_size"] = int(extraction["batch_size"])
        if "custom_instructions" in extraction:
            kwargs["extraction_custom_instructions"] = str(extraction["custom_instructions"])
        if "max_claims_per_extraction" in extraction:
            kwargs["max_claims_per_extraction"] = int(extraction["max_claims_per_extraction"])
        if "max_entities_per_extraction" in extraction:
            kwargs["max_entities_per_extraction"] = int(extraction["max_entities_per_extraction"])
        if "max_concurrent_extractions" in extraction:
            kwargs["max_concurrent_extractions"] = int(extraction["max_concurrent_extractions"])
        if "extraction_prompt_version" in extraction:
            kwargs["extraction_prompt_version"] = str(extraction["extraction_prompt_version"])
        if "known_subjects" in extraction:
            kwargs["known_subjects"] = list(extraction["known_subjects"])
        if "temporal_markers" in extraction:
            kwargs["extraction_temporal_markers"] = bool(extraction["temporal_markers"])
        if "extract_assistant_turns" in extraction:
            kwargs["extract_assistant_turns"] = bool(extraction["extract_assistant_turns"])
        if "extraction_mode" in extraction:
            kwargs["extraction_mode"] = str(extraction["extraction_mode"])
        if "zero_claim_log_level" in extraction:
            kwargs["extraction_zero_claim_log_level"] = str(extraction["zero_claim_log_level"]).upper()
        if "zero_claim_log_preview" in extraction:
            kwargs["extraction_zero_claim_log_preview"] = bool(extraction["zero_claim_log_preview"])

        # Entity Resolution
        entity_resolution = cortex.get("entity_resolution", {})
        if "candidate_k" in entity_resolution:
            kwargs["entity_resolution_candidate_k"] = int(entity_resolution["candidate_k"])
        if "batch_size" in entity_resolution:
            kwargs["entity_resolution_batch_size"] = int(entity_resolution["batch_size"])

        # Reconciliation
        if "model" in reconciliation:
            kwargs["reconciliation_model"] = reconciliation["model"]
        if "dialectic_model" in reconciliation:
            val = reconciliation["dialectic_model"]
            kwargs["dialectic_model"] = str(val) if val is not None else None

        # Token budgets
        if "cornerstone" in token_budgets:
            kwargs["cornerstone_token_budget"] = token_budgets["cornerstone"]
        if "retrieval" in token_budgets:
            kwargs["retrieval_token_budget"] = token_budgets["retrieval"]
        if "total_memory" in token_budgets:
            kwargs["total_memory_token_budget"] = token_budgets["total_memory"]
        # Issue #374 — hard context-window budget fractions
        if "memory_fraction" in token_budgets:
            kwargs["token_budget_memory_fraction"] = float(token_budgets["memory_fraction"])
        if "user_fraction" in token_budgets:
            kwargs["token_budget_user_fraction"] = float(token_budgets["user_fraction"])
        if "reserve_fraction" in token_budgets:
            kwargs["token_budget_reserve_fraction"] = float(token_budgets["reserve_fraction"])
        if "slot_priorities" in token_budgets:
            priorities = token_budgets["slot_priorities"]
            if isinstance(priorities, list) and all(isinstance(p, str) for p in priorities):
                kwargs["token_budget_slot_priorities"] = priorities

        # Drift detection
        if "check_threshold" in drift:
            kwargs["drift_check_threshold"] = drift["check_threshold"]
        if "restore_threshold" in drift:
            kwargs["drift_restore_threshold"] = drift["restore_threshold"]
        if "anti_compression_confidence" in drift:
            kwargs["anti_compression_confidence"] = drift["anti_compression_confidence"]

        # Brain graph watcher
        if "watch_dirs" in brain_graph:
            kwargs["brain_graph_watch_dirs"] = brain_graph["watch_dirs"]
        if "include_patterns" in brain_graph:
            kwargs["brain_graph_include_patterns"] = brain_graph["include_patterns"]
        if "exclude_patterns" in brain_graph:
            kwargs["brain_graph_exclude_patterns"] = brain_graph["exclude_patterns"]
        if "debounce_seconds" in brain_graph:
            kwargs["brain_graph_debounce_seconds"] = brain_graph["debounce_seconds"]

        # Cornerstones
        if "max_count" in cornerstones:
            kwargs["max_cornerstone_count"] = cornerstones["max_count"]
        if "drift_threshold" in cornerstones:
            kwargs["cornerstone_drift_threshold"] = cornerstones["drift_threshold"]
        if "auto_apply" in cornerstones:
            kwargs["cornerstone_auto_apply"] = cornerstones["auto_apply"]
        if "auto_apply_threshold" in cornerstones:
            val = cornerstones["auto_apply_threshold"]
            kwargs["cornerstone_auto_apply_threshold"] = float(val) if val is not None else None

        # Confidence defaults
        if "session_default" in confidence:
            kwargs["session_confidence_default"] = confidence["session_default"]
        if "promoted_default" in confidence:
            kwargs["promoted_confidence_default"] = confidence["promoted_default"]

        # Fatigue thresholds
        if "nap_threshold" in fatigue:
            kwargs["session_fatigue_nap_threshold"] = fatigue["nap_threshold"]
        if "sleep_threshold" in fatigue:
            kwargs["session_fatigue_sleep_threshold"] = fatigue["sleep_threshold"]

        # Circadian
        if "auto_sleep_after_minutes" in circadian:
            kwargs["auto_sleep_after_minutes"] = circadian["auto_sleep_after_minutes"]
        if "dream_cycle_enabled" in circadian:
            kwargs["dream_cycle_enabled"] = circadian["dream_cycle_enabled"]
        if "deep_dream_enabled" in circadian:
            kwargs["deep_dream_enabled"] = bool(circadian["deep_dream_enabled"])
        if "dream_cycle_hour_utc" in circadian:
            kwargs["dream_cycle_hour_utc"] = circadian["dream_cycle_hour_utc"]
        if "idle_timeout_sec" in circadian:
            kwargs["dream_cycle_idle_timeout_sec"] = circadian["idle_timeout_sec"]
        if "dream_cycle_timeout_sec" in circadian:
            kwargs["dream_cycle_timeout_sec"] = int(circadian["dream_cycle_timeout_sec"])
        if "fatigue_interval_sec" in circadian:
            kwargs["fatigue_interval_sec"] = int(circadian["fatigue_interval_sec"])

        # Retrieval
        if "default_strategy" in retrieval:
            kwargs["retrieval_default_strategy"] = retrieval["default_strategy"]
        if "max_tokens" in retrieval:
            kwargs["retrieval_max_tokens"] = retrieval["max_tokens"]
        if "vector_weight" in retrieval:
            kwargs["retrieval_vector_weight"] = retrieval["vector_weight"]
        if "bm25_weight" in retrieval:
            kwargs["retrieval_bm25_weight"] = retrieval["bm25_weight"]
        if "recency_weight" in retrieval:
            kwargs["retrieval_recency_weight"] = float(retrieval["recency_weight"])
        if "recency_half_life_days" in retrieval:
            kwargs["retrieval_recency_half_life_days"] = float(retrieval["recency_half_life_days"])
        if "explicitness_bonus" in retrieval:
            kwargs["retrieval_explicitness_bonus"] = float(retrieval["explicitness_bonus"])
        if "stability_bonus" in retrieval:
            kwargs["retrieval_stability_bonus"] = float(retrieval["stability_bonus"])
        if "temporal_boost_weight" in retrieval:
            kwargs["retrieval_temporal_boost_weight"] = float(retrieval["temporal_boost_weight"])
        if "rrf_k" in retrieval:
            kwargs["retrieval_rrf_k"] = int(retrieval["rrf_k"])
        if "max_chained_hops" in retrieval:
            kwargs["retrieval_max_chained_hops"] = int(retrieval["max_chained_hops"])
        if "confidence_threshold" in retrieval:
            kwargs["retrieval_confidence_threshold"] = float(retrieval["confidence_threshold"])

        # Status penalties (#574)
        status_penalties = retrieval.get("status_penalties", {})
        if status_penalties:
            kwargs["retrieval_status_penalties"] = {
                str(k): float(v) for k, v in status_penalties.items()
            }
        # Retrieval rerank (Voyage rerank-2.5 family, #502)
        rerank = retrieval.get("rerank", {})
        if "enabled" in rerank:
            kwargs["retrieval_rerank_enabled"] = bool(rerank["enabled"])
        if "model" in rerank:
            kwargs["retrieval_rerank_model"] = str(rerank["model"])
        if "top_k" in rerank:
            kwargs["retrieval_rerank_top_k"] = int(rerank["top_k"])
        if "initial_candidates" in rerank:
            kwargs["retrieval_rerank_initial_candidates"] = int(rerank["initial_candidates"])

        # Deprecation
        # grace_period_hours → cooling_days (convert hours → days, min 1)
        if "grace_period_hours" in deprecation:
            kwargs["deprecation_cooling_days"] = max(
                1, deprecation["grace_period_hours"] // 24
            )
        if "archive_days" in deprecation:
            kwargs["deprecation_archive_days"] = deprecation["archive_days"]

        # LLM call budgets
        if "max_llm_calls_per_session" in budgets:
            kwargs["max_llm_calls_per_session"] = budgets["max_llm_calls_per_session"]
        if "max_extraction_calls_per_batch" in budgets:
            kwargs["max_extraction_calls_per_batch"] = budgets["max_extraction_calls_per_batch"]

        # Commitments / open loops
        commitments = cortex.get("commitments", {})
        if "open_loop_stale_threshold" in commitments:
            kwargs["open_loop_stale_threshold"] = int(commitments["open_loop_stale_threshold"])

        # Promotion scoring (#376)
        promotion = cortex.get("promotion", {})
        promotion_weights = promotion.get("weights", {})
        if "evidence_strength" in promotion_weights:
            kwargs["promotion_weight_evidence_strength"] = float(promotion_weights["evidence_strength"])
        if "cross_session_frequency" in promotion_weights:
            kwargs["promotion_weight_cross_session_frequency"] = float(promotion_weights["cross_session_frequency"])
        if "user_explicitness" in promotion_weights:
            kwargs["promotion_weight_user_explicitness"] = float(promotion_weights["user_explicitness"])
        if "retrieval_utility" in promotion_weights:
            kwargs["promotion_weight_retrieval_utility"] = float(promotion_weights["retrieval_utility"])
        if "temporal_stability" in promotion_weights:
            kwargs["promotion_weight_temporal_stability"] = float(promotion_weights["temporal_stability"])
        if "contradiction_absence" in promotion_weights:
            kwargs["promotion_weight_contradiction_absence"] = float(promotion_weights["contradiction_absence"])
        if "threshold" in promotion:
            kwargs["promotion_threshold"] = float(promotion["threshold"])
        if "min_sessions" in promotion:
            kwargs["promotion_min_sessions"] = int(promotion["min_sessions"])

        # Auth (#542)
        auth_section = cortex.get("auth", {})
        if "jwt_secret" in auth_section:
            kwargs["auth_jwt_secret"] = str(auth_section["jwt_secret"])
        if "api_key" in auth_section:
            kwargs["auth_api_key"] = str(auth_section["api_key"])
        if "require_auth" in auth_section:
            kwargs["auth_require_auth"] = bool(auth_section["require_auth"])
        if "public_endpoints" in auth_section:
            eps = auth_section["public_endpoints"]
            if isinstance(eps, list) and all(isinstance(e, str) for e in eps):
                kwargs["auth_public_endpoints"] = eps

        # Rate limiting (#71)
        rate_limiting = cortex.get("rate_limiting", {})
        if "rpm" in rate_limiting:
            kwargs["rate_limit_rpm"] = int(rate_limiting["rpm"])
        if "burst" in rate_limiting:
            kwargs["rate_limit_burst"] = int(rate_limiting["burst"])

        # Per-provider LLM rate limits (extraction pipeline)
        providers_section = raw.get("providers", {})
        provider_rate_limits_raw = providers_section.get("rate_limits", {})
        if provider_rate_limits_raw and isinstance(provider_rate_limits_raw, dict):
            kwargs["provider_rate_limits"] = {
                str(k): dict(v) if isinstance(v, dict) else v
                for k, v in provider_rate_limits_raw.items()
            }

        # Pagination (L2 audit fix)
        pagination = cortex.get("pagination", {})
        if "max_page_size" in pagination:
            kwargs["max_page_size"] = int(pagination["max_page_size"])

        # Remember body size limit (Issue #486)
        api_section = cortex.get("api", {})
        if "max_remember_body_bytes" in api_section:
            kwargs["max_remember_body_bytes"] = int(api_section["max_remember_body_bytes"])

        # Log retention / TTL (L3 audit fix)
        log_retention = cortex.get("log_retention", {})
        if "retention_days" in log_retention:
            kwargs["log_retention_days"] = int(log_retention["retention_days"])

        # Working Memory Cache (#371)
        working_memory = cortex.get("working_memory", {})
        if "enabled" in working_memory:
            kwargs["working_memory_enabled"] = bool(working_memory["enabled"])
        if "max_items" in working_memory:
            kwargs["working_memory_max_items"] = int(working_memory["max_items"])
        if "default_ttl_seconds" in working_memory:
            kwargs["working_memory_default_ttl_seconds"] = int(working_memory["default_ttl_seconds"])

        # Embedding Cache (v1.2.1)
        embedding_cache = cortex.get("embedding_cache", {})
        if "enabled" in embedding_cache:
            kwargs["embedding_cache_enabled"] = bool(embedding_cache["enabled"])
        if "max_size" in embedding_cache:
            kwargs["embedding_cache_max_size"] = int(embedding_cache["max_size"])
        if "ttl_seconds" in embedding_cache:
            kwargs["embedding_cache_ttl_seconds"] = int(embedding_cache["ttl_seconds"])

        # DB Pool (#551)
        db_pool = cortex.get("db_pool", {})
        if "max_open" in db_pool:
            kwargs["db_pool_max_open"] = int(db_pool["max_open"])
        if "idle_timeout_seconds" in db_pool:
            kwargs["db_pool_idle_timeout_seconds"] = float(db_pool["idle_timeout_seconds"])
        if "data_dir" in db_pool:
            kwargs["db_pool_data_dir"] = str(db_pool["data_dir"])

        # Model routing (#375)
        model_routing = cortex.get("model_routing", {})
        if "enabled" in model_routing:
            kwargs["model_routing_enabled"] = bool(model_routing["enabled"])
        tiers = model_routing.get("tiers", {})
        if "fast" in tiers:
            kwargs["model_routing_fast"] = str(tiers["fast"])
        if "main" in tiers:
            kwargs["model_routing_main"] = str(tiers["main"])
        if "reasoning" in tiers:
            kwargs["model_routing_reasoning"] = str(tiers["reasoning"])
        if "operation_map" in model_routing:
            op_map = model_routing["operation_map"]
            if isinstance(op_map, dict):
                kwargs["model_routing_operation_map"] = {
                    str(k): str(v) for k, v in op_map.items()
                }

        # Metrics (#368)
        metrics_section = cortex.get("metrics", {})
        if "enabled" in metrics_section:
            kwargs["metrics_enabled"] = bool(metrics_section["enabled"])
        if "export_format" in metrics_section:
            kwargs["metrics_export_format"] = str(metrics_section["export_format"])

        # Quality Gates (#368)
        quality_gates_section = cortex.get("quality_gates", {})
        if "enabled" in quality_gates_section:
            kwargs["quality_gates_enabled"] = bool(quality_gates_section["enabled"])

        # Feature Flags (#368)
        feature_flags_section = cortex.get("feature_flags", {})
        if "enabled" in feature_flags_section:
            kwargs["feature_flags_enabled"] = bool(feature_flags_section["enabled"])

        # Circuit breaker (#347)
        circuit_breaker = cortex.get("circuit_breaker", {})
        if "enabled" in circuit_breaker:
            kwargs["circuit_breaker_enabled"] = bool(circuit_breaker["enabled"])
        if "threshold" in circuit_breaker:
            kwargs["circuit_breaker_threshold"] = int(circuit_breaker["threshold"])
        if "cooldown_sec" in circuit_breaker:
            kwargs["circuit_breaker_cooldown_sec"] = int(circuit_breaker["cooldown_sec"])

        # Re-Retrieval (#372)
        re_retrieval = cortex.get("re_retrieval", {})
        if "enabled" in re_retrieval:
            kwargs["re_retrieval_enabled"] = bool(re_retrieval["enabled"])
        if "max_per_turn" in re_retrieval:
            kwargs["re_retrieval_max_per_turn"] = int(re_retrieval["max_per_turn"])
        if "topic_shift_threshold" in re_retrieval:
            kwargs["re_retrieval_topic_shift_threshold"] = float(re_retrieval["topic_shift_threshold"])
        if "min_budget_tokens" in re_retrieval:
            kwargs["re_retrieval_min_budget_tokens"] = int(re_retrieval["min_budget_tokens"])

        # Context Compiler (#363)
        context_compiler = cortex.get("context_compiler", {})
        if "enabled" in context_compiler:
            kwargs["context_compiler_enabled"] = bool(context_compiler["enabled"])
        if "max_bundles" in context_compiler:
            kwargs["context_compiler_max_bundles"] = int(context_compiler["max_bundles"])
        if "budget_overflow_strategy" in context_compiler:
            kwargs["context_compiler_budget_overflow_strategy"] = str(context_compiler["budget_overflow_strategy"])

        # Boot Loader (#361)
        boot = cortex.get("boot", {})
        if "enabled" in boot:
            kwargs["boot_enabled"] = bool(boot["enabled"])
        if "max_recent_sessions" in boot:
            kwargs["boot_max_recent_sessions"] = int(boot["max_recent_sessions"])
        if "min_stability_threshold" in boot:
            kwargs["boot_min_stability_threshold"] = float(boot["min_stability_threshold"])
        if "budget_fraction" in boot:
            kwargs["boot_budget_fraction"] = float(boot["budget_fraction"])

        # Retrieval Policy Engine (#362)
        retrieval_policy = cortex.get("retrieval_policy", {})
        if "enabled" in retrieval_policy:
            kwargs["retrieval_policy_enabled"] = bool(retrieval_policy["enabled"])
        if "confidence_threshold" in retrieval_policy:
            kwargs["retrieval_policy_confidence_threshold"] = float(retrieval_policy["confidence_threshold"])
        if "default_mode" in retrieval_policy:
            kwargs["retrieval_policy_default_mode"] = str(retrieval_policy["default_mode"])

        # Context Window Calculator (#329)
        context_calculator = cortex.get("context_calculator", {})
        if "enabled" in context_calculator:
            kwargs["context_calculator_enabled"] = bool(context_calculator["enabled"])
        if "reserve_fraction" in context_calculator:
            kwargs["context_calculator_reserve_fraction"] = float(context_calculator["reserve_fraction"])
        if "allocation_strategy" in context_calculator:
            kwargs["context_calculator_allocation_strategy"] = str(context_calculator["allocation_strategy"])
        category_weights = context_calculator.get("category_weights", {})
        if category_weights and isinstance(category_weights, dict):
            kwargs["context_calculator_category_weights"] = {
                str(k): float(v) for k, v in category_weights.items()
            }

        # Debug logging
        debug = cortex.get("debug", {})
        if "enabled" in debug:
            kwargs["debug_logging_enabled"] = bool(debug["enabled"])
        if "max_entries" in debug:
            kwargs["debug_log_max_entries"] = int(debug["max_entries"])

        # Shared stats DB
        stats = cortex.get("stats", {})
        if "enabled" in stats:
            kwargs["stats_enabled"] = bool(stats["enabled"])
        if "db_path" in stats:
            kwargs["stats_db_path"] = str(stats["db_path"])
        if "prune_after_days" in stats:
            kwargs["stats_prune_after_days"] = int(stats["prune_after_days"])

        return cls(**{k: v for k, v in kwargs.items() if k in {f.name for f in fields(cls)}})

    @classmethod
    def _env_overrides(cls) -> dict[str, Any]:
        """Build a raw-style overlay dict from CORTEX_* environment variables.

        Environment variables are mapped to CortexConfig field names:
            CORTEX_<FIELD_NAME_UPPER> → field_name_lower

        Examples:
            CORTEX_DB_PATH=/data/cortex.db     → storage_db_path = "/data/cortex.db"
            CORTEX_EXTRACTION_ENABLED=false     → extraction_enabled = False
            CORTEX_EMBEDDING_DIM=768            → embedding_dim = 768

        The mapping goes directly into kwargs for the CortexConfig constructor,
        bypassing the TOML section structure.  Type coercion is based on the
        dataclass field's default value type.

        Returns:
            Dict suitable for passing as ``**kwargs`` to ``CortexConfig()``.
            Wrapped in ``{"__env__": {...}}`` so ``_from_raw`` can pick it up.
        """
        valid_fields = {f.name: f for f in fields(cls)}
        env_prefix = "CORTEX_"
        overrides: dict[str, Any] = {}

        for key, value in os.environ.items():
            if not key.startswith(env_prefix):
                continue
            field_name = key[len(env_prefix):].lower()
            if field_name not in valid_fields:
                continue

            f = valid_fields[field_name]
            try:
                overrides[field_name] = _coerce_env_value(value, f)
            except (ValueError, TypeError):
                import warnings
                warnings.warn(
                    f"Ignoring env var {key}={value!r}: "
                    f"cannot coerce to {f.type}",
                    stacklevel=2,
                )

        return overrides

    @classmethod
    def defaults(cls) -> "CortexConfig":
        """Return config loaded purely from the package defaults.toml."""
        return cls.from_toml()

    def db_path_resolved(self, base: Optional[str | Path] = None) -> Path:
        """Resolve storage_db_path relative to *base* (or cwd if None).

        Args:
            base: Base directory for relative paths. If None, uses os.getcwd().

        Returns:
            Absolute Path to the database file.
        """
        p = Path(self.storage_db_path)
        if p.is_absolute():
            return p
        if base is not None:
            return Path(base) / p
        return Path.cwd() / p
