"""DecisionLogger — traceable "why" behind every agent action.

Closes the accountability gap: ActivityLogger records WHAT the agent did
(tool X called, subagent Y dispatched, vault file Z written). DecisionLogger
records WHY — which skill was active, which contract rule permitted it, what
triggered the decision chain, which agent made the call.

Every decision record answers three questions:
    1. WHO did this?         (actor — main, ops-supervisor, researcher, ...)
    2. WHAT was the action?  (action_type + target — `tool:send_email`, ...)
    3. WHY was it allowed?   (contract_action + contract_result)
    4. WHAT was the context? (active skills, triggering source, rationale)

Storage: SQLite table `decision_log` at ~/.agntspace/agntspace.db — separate
from the audit trail (which captures contract checks) and from ActivityLogger
(which captures events). Decisions are the join: they link each action to the
rule + context that drove it.

Query path: `GET /api/decisions?actor=&action_type=&since=&limit=` for the
Accountability UI.
"""

from __future__ import annotations

import asyncio
import contextvars
import json
import logging
import uuid
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import aiosqlite

log = logging.getLogger(__name__)


# ── Correlation ID context (Rule #13 causal chain threading) ──
# A single correlation ID groups every event, decision, and activity entry
# that belongs to the same causal chain: "user sent a chat message", "cron
# fired at 09:00", "channel message from WhatsApp". Use the helpers below
# to begin a chain; nested awaits inherit it via contextvars, just like
# OpenTelemetry trace IDs. Each entry is opt-in — we never require a
# correlation_id, since most calls from background services don't need one.

_correlation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "agntspace_correlation_id", default="",
)

# ── Contract-authorized actions (VaultWriter runtime guard) ──
# This set tracks which contract actions the ContractEnforcer has allowed
# (or confirmed) inside the current correlation scope. It's populated by
# `ContractEnforcer.check()` whenever a check returns `allowed=True`, and
# read by `VaultWriter.write()` / `.append()` to verify that a write
# inside a correlation scope is backed by a real contract decision.
#
# This is the teeth behind Rule #3 (Contract is the law). Before the
# runtime guard existed, a malicious plugin or a badly-written internal
# caller could import `VaultWriter` and write anywhere, because the
# contract was only checked in the agent tool loop BEFORE the writer
# was invoked — nothing enforced that the write was actually preceded by
# a successful check. The authorized-actions set fixes that by making
# the check result visible to the writer, per correlation scope.
#
# Semantics:
#   - Fresh empty frozenset at the start of every correlation scope
#   - ContractEnforcer.check() adds the action on allowed/confirmed
#   - VaultWriter.write() reads via `is_action_authorized()`
#   - Scope exit restores the previous state (nested scopes don't leak)
#   - Outside any correlation scope, the set is empty and the guard
#     falls back to "trusted internal write" semantics (see writer.py)
_authorized_actions_var: contextvars.ContextVar[frozenset[str]] = contextvars.ContextVar(
    "agntspace_authorized_actions", default=frozenset(),
)

# ── Mutation counter (Agent.invoke_skill zero-effect detector) ──
#
# When a caller invokes a skill through the agent (watcher, heartbeat, cron,
# webhook handler, daily cycle), the LLM might respond with plausible-sounding
# narration — "I ingested the files!" — WITHOUT ever calling a real tool.
# Small local models do this often; cloud models do it occasionally under
# load. The caller has no way to distinguish "real work happened" from
# "fabricated success" unless we count the mutations that actually occurred
# inside the correlation scope.
#
# `ScopeMutations` is a lightweight per-scope counter. `Agent.invoke_skill()`
# installs one at the start of a skill run via `install_scope_mutations()`,
# then reads it after the dispatch returns. VaultWriter and the tool registry
# increment it on every real side effect. If the counter is still at zero
# when the skill claims success, the outcome is flagged `SkillZeroEffect`
# and the caller enqueues to the work queue instead of trusting the narrative.
#
# The counter is deliberately separate from correlation_scope — most scopes
# (chat turns, generic API requests) don't need the overhead. It's opt-in:
# `install_scope_mutations()` creates one when needed, `mutation_counter()`
# returns None when no counter is active, and all increment helpers
# short-circuit on None.


@dataclass
class ScopeMutations:
    """Counts verifiable mutations inside a skill invocation scope.

    Fields:
      - ``vault_writes``: real ``VaultWriter.write()``/``append()`` calls
        that actually touched the filesystem (not dry-runs, not raised).
      - ``tool_calls``: tools that actually executed through the registry
        (not hallucinated names, not blocked by contract). Counts one per
        successful invocation — not per token.
      - ``graph_triples``: triples added to the knowledge graph.

    A skill run with all three at zero is almost certainly a hallucinated
    success and should be re-queued.
    """

    vault_writes: int = 0
    tool_calls: int = 0
    graph_triples: int = 0

    def is_zero_effect(self) -> bool:
        return self.vault_writes == 0 and self.tool_calls == 0 and self.graph_triples == 0

    def summary(self) -> dict[str, int]:
        return {
            "vault_writes": self.vault_writes,
            "tool_calls": self.tool_calls,
            "graph_triples": self.graph_triples,
        }


_scope_mutations_var: contextvars.ContextVar[ScopeMutations | None] = contextvars.ContextVar(
    "agntspace_scope_mutations", default=None,
)

# ── Goal-scoped cost attribution ──
# When the heartbeat dispatches a goal pursuit via the work queue, this
# contextvar carries the goal slug so every ``CostTracker.record()`` call
# inside the scope attributes the cost to the right goal. Set by the
# ``_wq_pursue_goal`` handler in main.py; read by ``Agent._record_cost``.
_goal_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "agntspace_goal_id", default="",
)

# ── Evolution-scoped run + policy attribution ──
# When the evolution layer wraps ``Agent.invoke_skill`` with a RunManifest,
# it sets these two contextvars so every downstream tool call / decision
# record / vault write knows which evolution run it belongs to and which
# policy drove it. Both default to empty strings when the evolution layer
# isn't wired or the run is outside its scope — the columns on decision_log
# also default to '' so pre-evolution rows remain distinguishable.
_run_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "agntspace_run_id", default="",
)
_policy_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
    "agntspace_policy_id", default="",
)


def install_scope_mutations() -> tuple[ScopeMutations, contextvars.Token]:
    """Install a fresh mutation counter on the current scope.

    Returns (counter, token). Pass the token back to
    ``reset_scope_mutations()`` when the scope ends. Prefer the
    ``mutation_scope()`` context manager over using this directly.

    Nested installs stack: the inner call gets its own counter, and on
    reset the outer counter is restored. Increments inside the inner
    scope therefore do NOT leak into the outer one, which is what you
    want — each ``invoke_skill`` verifies its own effect, not its
    parent's.
    """
    counter = ScopeMutations()
    token = _scope_mutations_var.set(counter)
    return counter, token


def reset_scope_mutations(token: contextvars.Token) -> None:
    try:
        _scope_mutations_var.reset(token)
    except (LookupError, ValueError):
        pass


def mutation_counter() -> ScopeMutations | None:
    """Return the current scope's mutation counter, or None if none is
    installed. Callers that want to *increment* should use the helpers
    below so they can be no-ops when no counter is active.
    """
    return _scope_mutations_var.get()


def note_vault_write() -> None:
    """Record a successful vault write in the current scope, if any."""
    c = _scope_mutations_var.get()
    if c is not None:
        c.vault_writes += 1


def note_tool_call() -> None:
    """Record a successful tool execution in the current scope, if any."""
    c = _scope_mutations_var.get()
    if c is not None:
        c.tool_calls += 1


def note_graph_triple(count: int = 1) -> None:
    """Record triples added to the knowledge graph in the current scope."""
    c = _scope_mutations_var.get()
    if c is not None and count > 0:
        c.graph_triples += count


def set_goal_id(goal_id: str) -> contextvars.Token:
    """Set the active goal for cost attribution. Returns a reset token."""
    return _goal_id_var.set(goal_id)


def current_goal_id() -> str:
    """Return the goal slug in scope, or empty string if none."""
    return _goal_id_var.get()


def set_run_id(run_id: str) -> contextvars.Token:
    """Set the active evolution run_id. Returns a reset token.

    Used by Agent.invoke_skill to propagate the run_id to every tool call
    and decision record made inside the skill invocation. Do NOT call
    this from arbitrary service code — run_ids are owned by the
    evolution layer.
    """
    return _run_id_var.set(run_id)


def reset_run_id(token: contextvars.Token) -> None:
    try:
        _run_id_var.reset(token)
    except (LookupError, ValueError):
        pass


def current_run_id() -> str:
    """Return the evolution run_id in scope, or empty string if none."""
    return _run_id_var.get()


def set_policy_id(policy_id: str) -> contextvars.Token:
    """Set the active evolution policy_id. Returns a reset token.

    Paired with ``set_run_id``; the selector chooses which policy will
    run and sets both before dispatching. Nested invoke_skill calls
    inherit the parent's policy_id via the contextvar — this is what
    prevents experimentation cascading through a causal chain.
    """
    return _policy_id_var.set(policy_id)


def reset_policy_id(token: contextvars.Token) -> None:
    try:
        _policy_id_var.reset(token)
    except (LookupError, ValueError):
        pass


def current_policy_id() -> str:
    """Return the evolution policy_id in scope, or empty string if none."""
    return _policy_id_var.get()


class mutation_scope:
    """Context manager that installs a mutation counter for its duration.

    Usage::

        with mutation_scope() as counter:
            await do_work()
        if counter.is_zero_effect():
            raise SkillZeroEffect(...)

    Typically called inside ``correlation_scope``; the two are
    independent so a caller can have one without the other.
    """

    def __init__(self) -> None:
        self._counter: ScopeMutations | None = None
        self._token: contextvars.Token | None = None

    def __enter__(self) -> ScopeMutations:
        self._counter, self._token = install_scope_mutations()
        return self._counter

    def __exit__(self, *exc: object) -> None:
        if self._token is not None:
            reset_scope_mutations(self._token)


def classify_tool_result(
    result: object,
    *,
    duration_ms: int = 0,
    yaml_rules: dict | None = None,
    tool_name: str = "",
) -> tuple[str, str]:
    """Classify a tool result dict into (result_status, error_type).

    Engine-level deterministic classifier. Handles the structural cases every
    tool result can be in, regardless of what the tool does. Semantic "is this
    tool's zero-length list a success or zero-effect?" rules live in the
    ``_meta/tool-outcome-classify`` skill's YAML and come in via ``yaml_rules``.

    Return values:
      - ("ok", "")              — dict without error key, did real work
      - ("failed", <ErrorType>) — dict with error key, or non-dict result
      - ("blocked", "contract_block") — contract_blocked / trust_blocked flag set
      - ("zero_effect", "empty_result") — tool returned structured empty success
        (YAML-driven per-tool rule matched, e.g. run_wiki_job ingested==0)
      - ("partial", "")         — YAML rule matched a partial-success pattern

    Never raises. Missing / malformed YAML rules fall through to deterministic
    defaults. Callers pass the classifier's output straight to
    ``DecisionLogger.record(result_status=, error_type=)``.
    """
    # Non-dict: anything that isn't a mapping from a tool is malformed.
    if not isinstance(result, dict):
        return "failed", "non_dict_result"

    # Contract / trust block (decided by pre-check in Agent._execute_tool, but
    # we respect the flags here too so callers that don't go through the
    # agent still get uniform classification).
    if result.get("contract_blocked") or (
        isinstance(result.get("error"), str) and "Contract blocked" in result["error"]
    ):
        return "blocked", "contract_block"
    if result.get("trust_blocked"):
        return "blocked", "trust_block"

    # Error key present → failure. Try to extract a short classifier from the
    # common `"<ErrorType>: <message>"` pattern; otherwise keep the bare type.
    err = result.get("error")
    if isinstance(err, str) and err:
        et = ""
        if ":" in err:
            head = err.split(":", 1)[0].strip()
            # Accept Python exception-ish class names only
            if head and head[0].isupper() and all(c.isalnum() or c == "_" for c in head):
                et = head
        # Heuristics for common engine errors that aren't an exception class
        low = err.lower()
        if not et:
            if "timed out" in low or "timeout" in low:
                et = "timeout"
            elif "unknown tool" in low:
                et = "unknown_tool"
            elif "no executor" in low:
                et = "no_executor"
            else:
                et = "error"
        return "failed", et

    # YAML-driven semantic rules (skill-owned strategy).
    # Shape expected in yaml_rules:
    #   {
    #     "zero_effect": {
    #       "<tool_name>": [
    #         {"field": "articles", "op": "eq", "value": 0},
    #         {"field": "ingested", "op": "eq", "value": 0},
    #       ],
    #       "<tool_name_2>": [...],
    #     },
    #     "partial": { "<tool_name>": [ ... ] },
    #   }
    # Rule semantics: ALL rules in a list must match for that status to apply.
    # First matching status wins (zero_effect is checked before partial).
    if yaml_rules and tool_name:
        for status in ("zero_effect", "partial"):
            tool_rules = (yaml_rules.get(status) or {}).get(tool_name)
            if tool_rules and _all_rules_match(result, tool_rules):
                return status, "empty_result" if status == "zero_effect" else ""

    return "ok", ""


def _all_rules_match(result: dict, rules: list[dict]) -> bool:
    """Return True if every rule in ``rules`` matches ``result``.

    A rule is ``{"field": <key>, "op": <op>, "value": <expected>}`` where
    ``op`` is one of ``eq | ne | lt | le | gt | ge | empty | nonempty``.
    Missing fields treat the value as None.
    """
    for rule in rules:
        if not isinstance(rule, dict):
            return False
        field_name = rule.get("field", "")
        op = rule.get("op", "eq")
        expected = rule.get("value")
        actual = result.get(field_name)
        try:
            if op == "eq":
                if actual != expected:
                    return False
            elif op == "ne":
                if actual == expected:
                    return False
            elif op == "lt":
                if not (actual is not None and actual < expected):
                    return False
            elif op == "le":
                if not (actual is not None and actual <= expected):
                    return False
            elif op == "gt":
                if not (actual is not None and actual > expected):
                    return False
            elif op == "ge":
                if not (actual is not None and actual >= expected):
                    return False
            elif op == "empty":
                if actual not in (None, "", [], {}, 0):
                    return False
            elif op == "nonempty":
                if actual in (None, "", [], {}, 0):
                    return False
            else:
                return False  # unknown op → rule doesn't match
        except TypeError:
            return False
    return True


def new_correlation_id(prefix: str = "") -> str:
    """Generate a short, unique correlation ID.

    Prefix is optional free-text categorization (`chat`, `cron`, `channel`).
    Result is `{prefix}-{8hex}` — short enough for logs, unique enough for
    day-scale traces. Store this when you start a causal chain.
    """
    short = uuid.uuid4().hex[:8]
    return f"{prefix}-{short}" if prefix else short


def set_correlation_id(corr_id: str) -> contextvars.Token:
    """Attach a correlation ID to the current async context.

    Returns a contextvars Token you must pass to `reset_correlation_id()`
    when the causal chain ends. Prefer the `correlation_scope()` context
    manager for automatic cleanup.
    """
    return _correlation_id_var.set(corr_id)


def reset_correlation_id(token: contextvars.Token) -> None:
    """Detach a previously-set correlation ID."""
    try:
        _correlation_id_var.reset(token)
    except (LookupError, ValueError):
        pass


def current_correlation_id() -> str:
    """Return the correlation ID in the current context, or empty string."""
    return _correlation_id_var.get()


def caller_from_correlation_id(cid: str) -> str:
    """Derive the caller/pipeline name from a correlation ID.

    `new_correlation_id(prefix)` produces `{prefix}-{8hex}`. Some prefixes
    themselves contain hyphens (`daily-briefing`, `wq-ingest`, `memory-bridge`,
    `skill-kb-compile`). Split on the LAST hyphen so the prefix is preserved
    intact. Returns "" for empty/bare IDs so the dashboard can group them
    under an "ad-hoc" bucket without misattributing them.

    Examples:
        caller_from_correlation_id("chat-abc12345")       -> "chat"
        caller_from_correlation_id("daily-briefing-ff00") -> "daily-briefing"
        caller_from_correlation_id("wq-ingest-1234abcd")  -> "wq-ingest"
        caller_from_correlation_id("abc12345")            -> ""
        caller_from_correlation_id("")                    -> ""
    """
    if not cid:
        return ""
    prefix, _, suffix = cid.rpartition("-")
    # A bare 8-hex id has no hyphen — rpartition returns ("", "", cid).
    # A real prefixed id has prefix non-empty and suffix matching hex.
    if not prefix:
        return ""
    return prefix


def mark_action_authorized(action: str) -> None:
    """Record that the contract has authorized `action` in the current scope.

    Called by `ContractEnforcer.check()` whenever a check returns
    `allowed=True` (including `requires_confirmation=True`, which is still
    a form of "the user allowed this action at the contract layer"). The
    action joins the current scope's authorized set, and every
    `VaultWriter.write(..., contract_action=action)` call inside this
    scope will pass the runtime guard.

    Uses copy-on-write to preserve contextvars semantics: a child async
    task that inherits the parent's set sees the update, but a sibling
    scope started via `correlation_scope()` gets a fresh empty set.
    """
    current = _authorized_actions_var.get()
    if action not in current:
        _authorized_actions_var.set(frozenset(current | {action}))


def mark_actions_authorized(actions: list[str]) -> None:
    """Bulk version of mark_action_authorized — pre-authorize multiple actions."""
    current = _authorized_actions_var.get()
    new = current | frozenset(actions)
    if new != current:
        _authorized_actions_var.set(new)


def is_action_authorized(action: str) -> bool:
    """Return True if `action` has been authorized in the current scope."""
    return action in _authorized_actions_var.get()


def authorized_actions_snapshot() -> frozenset[str]:
    """Return the full authorized-actions set for the current scope.

    Useful for diagnostics and for the VaultWriter guard's error messages
    — we want the user to see WHICH actions were authorized when a write
    is rejected, not just "no contract action matches".
    """
    return _authorized_actions_var.get()


def reset_authorized_actions() -> contextvars.Token:
    """Reset the authorized-actions set to empty and return a restore token.

    Use this at the start of a new scope boundary that isn't managed by
    `correlation_scope` — specifically, the HTTP correlation middleware
    (which uses set_correlation_id directly to stay async-friendly) and
    the agent chat loop (which opens its own scope without the context
    manager). Pair with `_restore_authorized_actions(token)` on scope
    exit to guarantee no cross-scope bleed.

    This exists because contextvars inherit from the parent task that
    started the current async chain. Without an explicit reset at a new
    scope boundary, the authorized-actions set would leak from whichever
    context spawned the current request handler.
    """
    return _authorized_actions_var.set(frozenset())


def _restore_authorized_actions(token: contextvars.Token) -> None:
    """Undo a previous `reset_authorized_actions()` call."""
    try:
        _authorized_actions_var.reset(token)
    except (LookupError, ValueError):
        pass


class correlation_scope:
    """Context manager that sets a correlation ID and clears it on exit.

    Also resets the contract-authorized actions set to an empty frozenset
    on entry, so every new correlation scope starts with a clean slate.
    This is what makes the VaultWriter runtime guard strict per-scope:
    authorized actions in scope A do NOT leak into scope B, even when B
    is started from inside A's handler (e.g., a subagent dispatch that
    creates its own scope).

    Example:
        with correlation_scope(new_correlation_id("chat")):
            await agent.chat(message)
            # every decision and activity event inside inherits the corr_id
            # every contract check inside populates the authorized set
            # every VaultWriter.write(contract_action=X) inside is guarded
    """

    def __init__(self, corr_id: str) -> None:
        self._corr_id = corr_id
        self._cid_token: contextvars.Token | None = None
        self._auth_token: contextvars.Token | None = None

    def __enter__(self) -> str:
        self._cid_token = set_correlation_id(self._corr_id)
        # Reset authorized actions to empty — a new scope starts clean
        self._auth_token = _authorized_actions_var.set(frozenset())
        return self._corr_id

    def __exit__(self, *exc: object) -> None:
        # Restore in reverse order so a nested scope's exit leaves the
        # parent scope's state exactly as it was, including its own
        # authorized actions set.
        if self._auth_token is not None:
            try:
                _authorized_actions_var.reset(self._auth_token)
            except (LookupError, ValueError):
                pass
        if self._cid_token is not None:
            reset_correlation_id(self._cid_token)


@dataclass
class Decision:
    """A single traceable agent decision — action + driving context + outcome."""

    actor: str                           # agent key: main, ops-supervisor, researcher, ...
    action_type: str                     # tool | dispatch | vault_write | auto_generate | contract_block
    action_target: str                   # tool name, agent key, vault path, etc.
    contract_action: str = ""            # the contract rule checked (e.g. "send_email")
    contract_result: str = "allowed"     # allowed | confirmed | blocked | exempt
    triggered_by: str = "unknown"        # user_message | workflow | cron | heartbeat | chat_tool | subagent | auto
    context_skills: list[str] = field(default_factory=list)
    context_memories: list[str] = field(default_factory=list)
    correlation_id: str = ""             # shared across every event in one causal chain
    rationale: str = ""                  # short human-readable "why"
    details: dict = field(default_factory=dict)
    goal_id: str = ""                    # which goal this decision serves (empty = not goal-driven)
    trust_level: str = ""                # effective trust level at decision time (Phase 5)
    trust_domain: str = ""               # trust domain that governed this action (Phase 5)
    # ── Outcome tracking (Fix 2, v1.3) ──
    # `result_status` answers "did this action actually accomplish something?"
    # — orthogonal to contract_result which answers "was this permitted?".
    # Allowed values:
    #   ""            — pre-migration row or caller didn't classify (treat as unknown)
    #   "ok"          — action completed and produced a real effect
    #   "failed"      — action raised / returned {"error": ...} / handler missing
    #   "zero_effect" — action ran without raising but produced no observable effect
    #                   (e.g. run_wiki_job with 0 articles compiled, empty result set)
    #   "blocked"     — contract or trust rejected before execution
    #   "partial"     — action completed some of its intended work (e.g. 2/5 scrapes)
    #   "pending"     — action still in flight (for deferred / queued work)
    result_status: str = ""
    duration_ms: int = 0                 # wall-clock milliseconds for the action
    error_type: str = ""                 # short classifier for failures ("timeout", "ValueError", ...)
    # ── Evolution attribution (v1.4) ──
    # run_id links this decision to an evolution_runs row (if the
    # decision fired inside an Agent.invoke_skill scope). policy_id
    # records which reflex (or POLICY_INCUMBENT_DEFAULT) drove the
    # routing choice for the enclosing run. Both default to '' so
    # pre-evolution rows remain distinguishable from runs that
    # explicitly chose the incumbent.
    run_id: str = ""
    policy_id: str = ""
    timestamp: str = ""

    def __post_init__(self) -> None:
        if not self.timestamp:
            from agntspace.infra.timezone import format_iso_local
            self.timestamp = format_iso_local(datetime.now(UTC))


class DecisionLogger:
    """SQLite-backed store for agent decision records.

    Thread-safe (single asyncio.Lock guards all writes). Non-fatal on failure
    — a broken decision log must never break the agent loop.
    """

    # Base schema (v1). New columns are added via _MIGRATIONS so an existing
    # v1 database gets upgraded in place without needing a manual migration.
    # Index on correlation_id is created inside _MIGRATIONS too, because on
    # first-run-after-migration the column may not yet exist when executescript
    # runs the inline CREATE INDEX statement.
    _SCHEMA = """
    CREATE TABLE IF NOT EXISTS decision_log (
        id               INTEGER PRIMARY KEY AUTOINCREMENT,
        ts               TEXT    NOT NULL,
        actor            TEXT    NOT NULL,
        action_type      TEXT    NOT NULL,
        action_target    TEXT    NOT NULL,
        contract_action  TEXT    DEFAULT '',
        contract_result  TEXT    DEFAULT 'allowed',
        triggered_by     TEXT    DEFAULT 'unknown',
        context_skills   TEXT    DEFAULT '[]',
        rationale        TEXT    DEFAULT '',
        details          TEXT    DEFAULT '{}'
    );
    CREATE INDEX IF NOT EXISTS idx_decision_ts            ON decision_log(ts);
    CREATE INDEX IF NOT EXISTS idx_decision_actor         ON decision_log(actor);
    CREATE INDEX IF NOT EXISTS idx_decision_action        ON decision_log(action_type);
    """

    # Migration helper — add columns when upgrading from v1 schema
    _MIGRATIONS = [  # arch:deterministic — SQL schema migrations, not strategy
        "ALTER TABLE decision_log ADD COLUMN context_memories TEXT DEFAULT '[]'",
        "ALTER TABLE decision_log ADD COLUMN correlation_id TEXT DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS idx_decision_correlation ON decision_log(correlation_id)",
        "ALTER TABLE decision_log ADD COLUMN goal_id TEXT DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS idx_decision_goal ON decision_log(goal_id)",
        "ALTER TABLE decision_log ADD COLUMN trust_level TEXT DEFAULT ''",
        "ALTER TABLE decision_log ADD COLUMN trust_domain TEXT DEFAULT ''",
        # v1.3 — outcome tracking (Fix 2). result_status is one of
        # {ok, failed, zero_effect, blocked, partial, pending}. duration_ms
        # is wall-clock milliseconds for the underlying action. error_type
        # is the short classifier ("timeout", "contract_block", "ValueError",
        # "empty_result", ...) — free-form, indexed on result_status only.
        # Pre-v1.3 rows default to result_status='' (distinguishable from
        # genuine 'ok') so callers can detect pre-migration rows cleanly.
        "ALTER TABLE decision_log ADD COLUMN result_status TEXT DEFAULT ''",
        "ALTER TABLE decision_log ADD COLUMN duration_ms INTEGER DEFAULT 0",
        "ALTER TABLE decision_log ADD COLUMN error_type TEXT DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS idx_decision_result ON decision_log(result_status)",
        # v1.4 — evolution attribution. Every decision in an invoke_skill
        # scope carries the evolution run_id (joins to evolution_runs) and
        # the policy_id that drove the enclosing run. Both default to ''
        # so pre-v1.4 rows are distinguishable from evolution runs that
        # explicitly chose the incumbent default.
        "ALTER TABLE decision_log ADD COLUMN policy_id TEXT DEFAULT ''",
        "ALTER TABLE decision_log ADD COLUMN run_id TEXT DEFAULT ''",
        "CREATE INDEX IF NOT EXISTS idx_decision_policy ON decision_log(policy_id)",
        "CREATE INDEX IF NOT EXISTS idx_decision_run ON decision_log(run_id)",
    ]

    def __init__(
        self,
        db: aiosqlite.Connection | None = None,
        *,
        max_rows: int = 100_000,
        max_age_days: int = 365,
    ) -> None:
        self._db = db
        self._lock = asyncio.Lock()
        self._initialized = False
        # Optional in-memory fallback when no db (tests)
        self._fallback: list[Decision] = []

        # ── Retention policy (gap #4) ──
        # max_rows: hard ceiling on decision_log rows. When exceeded,
        # oldest rows are pruned.
        # max_age_days: rows older than this are pruned regardless of count.
        # Runs on demand via `prune()` — typically called by a heartbeat
        # task every N hours, not on every write.
        self._max_rows = max_rows
        self._max_age_days = max_age_days
        self._last_prune_iso: str = ""
        self._total_pruned: int = 0

        # ── Meta-monitoring (gap #10) ──
        # Counters that expose the health of the observability layer itself.
        # Without these, a silent except-pass could hide losses indefinitely.
        self._record_attempts: int = 0
        self._record_failures: int = 0
        self._record_last_error: str = ""
        self._record_last_error_ts: str = ""

    async def initialize(self) -> None:
        """Create the table. Safe to call multiple times."""
        if self._initialized or self._db is None:
            self._initialized = True
            return
        async with self._lock:
            try:
                await self._db.executescript(self._SCHEMA)
                # Run idempotent migrations — non-fatal on "column already exists"
                for stmt in self._MIGRATIONS:
                    try:
                        await self._db.execute(stmt)
                    except Exception:
                        pass  # already applied
                await self._db.commit()
                self._initialized = True
            except Exception:
                log.exception("Failed to initialize decision_log table")

    async def record(
        self,
        actor: str,
        action_type: str,
        action_target: str,
        *,
        contract_action: str = "",
        contract_result: str = "allowed",
        triggered_by: str = "unknown",
        context_skills: list[str] | None = None,
        context_memories: list[str] | None = None,
        correlation_id: str = "",
        rationale: str = "",
        details: dict | None = None,
        goal_id: str = "",
        trust_level: str = "",
        trust_domain: str = "",
        result_status: str = "",
        duration_ms: int = 0,
        error_type: str = "",
        run_id: str = "",
        policy_id: str = "",
    ) -> None:
        """Record a single decision. Never raises — fatal logging is worse than missing a decision."""
        decision = Decision(
            actor=actor,
            action_type=action_type,
            action_target=action_target,
            contract_action=contract_action,
            contract_result=contract_result,
            triggered_by=triggered_by,
            context_skills=list(context_skills or []),
            context_memories=list(context_memories or []),
            correlation_id=correlation_id or current_correlation_id(),
            rationale=rationale,
            details=details or {},
            goal_id=goal_id,
            trust_level=trust_level,
            trust_domain=trust_domain,
            result_status=result_status,
            duration_ms=int(duration_ms) if duration_ms else 0,
            error_type=error_type,
            run_id=run_id or current_run_id(),
            policy_id=policy_id or current_policy_id(),
        )

        self._record_attempts += 1

        if self._db is None:
            self._fallback.append(decision)
            return

        if not self._initialized:
            await self.initialize()

        try:
            async with self._lock:
                await self._db.execute(
                    "INSERT INTO decision_log "
                    "(ts, actor, action_type, action_target, contract_action, "
                    "contract_result, triggered_by, context_skills, context_memories, "
                    "correlation_id, rationale, details, goal_id, "
                    "trust_level, trust_domain, "
                    "result_status, duration_ms, error_type, "
                    "policy_id, run_id) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    (
                        decision.timestamp,
                        decision.actor,
                        decision.action_type,
                        decision.action_target,
                        decision.contract_action,
                        decision.contract_result,
                        decision.triggered_by,
                        json.dumps(decision.context_skills),
                        json.dumps(decision.context_memories),
                        decision.correlation_id,
                        decision.rationale,
                        json.dumps(decision.details, default=str),
                        decision.goal_id,
                        decision.trust_level,
                        decision.trust_domain,
                        decision.result_status,
                        decision.duration_ms,
                        decision.error_type,
                        decision.policy_id,
                        decision.run_id,
                    ),
                )
                await self._db.commit()
        except Exception as exc:
            self._record_failures += 1
            self._record_last_error = str(exc)[:200]
            self._record_last_error_ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
            log.debug("DecisionLogger.record failed", exc_info=True)

    def record_sync(
        self,
        actor: str,
        action_type: str,
        action_target: str,
        *,
        contract_action: str = "",
        contract_result: str = "allowed",
        triggered_by: str = "unknown",
        context_skills: list[str] | None = None,
        context_memories: list[str] | None = None,
        correlation_id: str = "",
        rationale: str = "",
        details: dict | None = None,
        trust_level: str = "",
        trust_domain: str = "",
    ) -> None:
        """Synchronous fire-and-forget wrapper. Schedules the async record call.

        Used by call sites that aren't in an async context (VaultWriter,
        sync service hooks). Falls back to the in-memory list if no event
        loop is running.
        """
        decision = Decision(
            actor=actor,
            action_type=action_type,
            action_target=action_target,
            contract_action=contract_action,
            contract_result=contract_result,
            triggered_by=triggered_by,
            context_skills=list(context_skills or []),
            context_memories=list(context_memories or []),
            correlation_id=correlation_id or current_correlation_id(),
            rationale=rationale,
            details=details or {},
            trust_level=trust_level,
            trust_domain=trust_domain,
            run_id=current_run_id(),
            policy_id=current_policy_id(),
        )
        if self._db is None:
            self._fallback.append(decision)
            return

        try:
            loop = asyncio.get_running_loop()
            loop.create_task(self.record(
                actor=actor,
                action_type=action_type,
                action_target=action_target,
                contract_action=contract_action,
                contract_result=contract_result,
                triggered_by=triggered_by,
                context_skills=context_skills,
                context_memories=context_memories,
                correlation_id=decision.correlation_id,
                rationale=rationale,
                details=details,
                trust_level=trust_level,
                trust_domain=trust_domain,
                run_id=decision.run_id,
                policy_id=decision.policy_id,
            ))
        except RuntimeError:
            # No running loop — keep the decision in memory so query() still returns it
            self._fallback.append(decision)

    async def query(
        self,
        *,
        actor: str | None = None,
        action_type: str | None = None,
        correlation_id: str | None = None,
        goal_id: str | None = None,
        since: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[dict]:
        """Return decisions matching filters, newest first."""
        if self._db is None:
            results = [asdict(d) for d in reversed(self._fallback)]
            if actor:
                results = [r for r in results if r["actor"] == actor]
            if action_type:
                results = [r for r in results if r["action_type"] == action_type]
            if correlation_id:
                results = [r for r in results if r["correlation_id"] == correlation_id]
            if goal_id:
                results = [r for r in results if r.get("goal_id") == goal_id]
            if since:
                results = [r for r in results if r["timestamp"] >= since]
            return results[offset : offset + limit]

        if not self._initialized:
            await self.initialize()

        clauses: list[str] = []
        params: list = []
        if actor:
            clauses.append("actor = ?")
            params.append(actor)
        if action_type:
            clauses.append("action_type = ?")
            params.append(action_type)
        if correlation_id:
            clauses.append("correlation_id = ?")
            params.append(correlation_id)
        if goal_id:
            clauses.append("goal_id = ?")
            params.append(goal_id)
        if since:
            clauses.append("ts >= ?")
            params.append(since)
        where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
        sql = (
            f"SELECT id, ts, actor, action_type, action_target, contract_action, "
            f"contract_result, triggered_by, context_skills, context_memories, "
            f"correlation_id, rationale, details, goal_id, "
            f"trust_level, trust_domain, "
            f"result_status, duration_ms, error_type, "
            f"policy_id, run_id "
            f"FROM decision_log {where} ORDER BY id DESC LIMIT ? OFFSET ?"
        )
        params.extend([limit, offset])

        try:
            cur = await self._db.execute(sql, params)
            rows = await cur.fetchall()
        except Exception:
            log.debug("DecisionLogger.query failed", exc_info=True)
            return []

        return [
            {
                "id": row[0],
                "timestamp": row[1],
                "actor": row[2],
                "action_type": row[3],
                "action_target": row[4],
                "contract_action": row[5],
                "contract_result": row[6],
                "triggered_by": row[7],
                "context_skills": json.loads(row[8]) if row[8] else [],
                "context_memories": json.loads(row[9]) if row[9] else [],
                "correlation_id": row[10] or "",
                "rationale": row[11],
                "details": json.loads(row[12]) if row[12] else {},
                "goal_id": row[13] or "",
                "trust_level": row[14] or "" if len(row) > 14 else "",
                "trust_domain": row[15] or "" if len(row) > 15 else "",
                "result_status": (row[16] or "") if len(row) > 16 else "",
                "duration_ms": (row[17] or 0) if len(row) > 17 else 0,
                "error_type": (row[18] or "") if len(row) > 18 else "",
                "policy_id": (row[19] or "") if len(row) > 19 else "",
                "run_id": (row[20] or "") if len(row) > 20 else "",
            }
            for row in rows
        ]

    async def stats(self) -> dict:
        """Aggregate decision stats by actor and action_type."""
        if self._db is None:
            by_actor: dict[str, int] = {}
            by_action: dict[str, int] = {}
            blocked = 0
            for d in self._fallback:
                by_actor[d.actor] = by_actor.get(d.actor, 0) + 1
                by_action[d.action_type] = by_action.get(d.action_type, 0) + 1
                if d.contract_result == "blocked":
                    blocked += 1
            return {
                "total": len(self._fallback),
                "by_actor": by_actor,
                "by_action_type": by_action,
                "blocked": blocked,
            }

        if not self._initialized:
            await self.initialize()

        try:
            total_cur = await self._db.execute("SELECT COUNT(*) FROM decision_log")
            total = (await total_cur.fetchone())[0]

            actor_cur = await self._db.execute(
                "SELECT actor, COUNT(*) FROM decision_log GROUP BY actor"
            )
            by_actor = dict(await actor_cur.fetchall())

            action_cur = await self._db.execute(
                "SELECT action_type, COUNT(*) FROM decision_log GROUP BY action_type"
            )
            by_action = dict(await action_cur.fetchall())

            blocked_cur = await self._db.execute(
                "SELECT COUNT(*) FROM decision_log WHERE contract_result = 'blocked'"
            )
            blocked = (await blocked_cur.fetchone())[0]
        except Exception:
            log.debug("DecisionLogger.stats failed", exc_info=True)
            return {"total": 0, "by_actor": {}, "by_action_type": {}, "blocked": 0}

        return {
            "total": total,
            "by_actor": by_actor,
            "by_action_type": by_action,
            "blocked": blocked,
        }

    async def prune(self) -> dict:
        """Apply the retention policy. Returns {by_age, by_count, total_pruned}.

        Policy:
          1. Delete rows older than `max_age_days` (regardless of count).
          2. If row count still exceeds `max_rows`, delete the oldest rows
             until we're back under the ceiling.

        Non-fatal on failure. Typically called every ~6 hours from a
        background task.
        """
        result = {"by_age": 0, "by_count": 0, "total_pruned": 0}
        if self._db is None:
            # Fallback list pruning
            if self._max_age_days > 0:
                from datetime import timedelta
                cutoff = (
                    datetime.now(UTC) - timedelta(days=self._max_age_days)
                ).strftime("%Y-%m-%dT%H:%M:%SZ")
                before = len(self._fallback)
                self._fallback = [d for d in self._fallback if d.timestamp >= cutoff]
                result["by_age"] = before - len(self._fallback)
            if len(self._fallback) > self._max_rows:
                excess = len(self._fallback) - self._max_rows
                # Keep the most recent N by timestamp
                self._fallback.sort(key=lambda d: d.timestamp)
                del self._fallback[:excess]
                result["by_count"] = excess
            result["total_pruned"] = result["by_age"] + result["by_count"]
            self._total_pruned += result["total_pruned"]
            self._last_prune_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
            return result

        if not self._initialized:
            await self.initialize()

        try:
            async with self._lock:
                # 1. Delete by age
                if self._max_age_days > 0:
                    from datetime import timedelta
                    cutoff = (
                        datetime.now(UTC) - timedelta(days=self._max_age_days)
                    ).strftime("%Y-%m-%dT%H:%M:%SZ")
                    cur = await self._db.execute(
                        "DELETE FROM decision_log WHERE ts < ?", (cutoff,),
                    )
                    result["by_age"] = cur.rowcount or 0

                # 2. Delete by count — keep the most recent `max_rows`
                if self._max_rows > 0:
                    total_cur = await self._db.execute(
                        "SELECT COUNT(*) FROM decision_log"
                    )
                    total = (await total_cur.fetchone())[0]
                    if total > self._max_rows:
                        excess = total - self._max_rows
                        cur = await self._db.execute(
                            "DELETE FROM decision_log WHERE id IN "
                            "(SELECT id FROM decision_log ORDER BY id ASC LIMIT ?)",
                            (excess,),
                        )
                        result["by_count"] = cur.rowcount or 0

                await self._db.commit()
        except Exception:
            log.debug("DecisionLogger.prune failed", exc_info=True)
            return result

        result["total_pruned"] = result["by_age"] + result["by_count"]
        self._total_pruned += result["total_pruned"]
        self._last_prune_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        return result

    def health(self) -> dict:
        """Meta-monitoring for the observability layer itself (gap #10).

        Surfaces how reliable the DecisionLogger has been since startup:
        attempts, failures, failure rate, last error + timestamp, last
        prune run, total rows pruned across the lifetime of this process.
        Returned synchronously so a health endpoint can read it without
        awaiting the db.
        """
        failure_rate = (
            self._record_failures / self._record_attempts
            if self._record_attempts > 0 else 0.0
        )
        return {
            "backend": "sqlite" if self._db is not None else "fallback",
            "initialized": self._initialized,
            "record_attempts": self._record_attempts,
            "record_failures": self._record_failures,
            "failure_rate": round(failure_rate, 4),
            "last_error": self._record_last_error,
            "last_error_ts": self._record_last_error_ts,
            "max_rows": self._max_rows,
            "max_age_days": self._max_age_days,
            "last_prune": self._last_prune_iso,
            "total_pruned": self._total_pruned,
            "fallback_size": len(self._fallback) if self._db is None else 0,
        }
