nexo-brain 7.37.4 → 7.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.37.4",
3
+ "version": "7.38.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,9 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.37.4` is the current packaged-runtime line. Patch release over v7.37.3 - product-gap reporting, stale briefing noise, and opportunity loops: Deep Sleep reports recurring NEXO product gaps through sanitized Desktop support tickets, self-audit preserves closed internal opportunities, the morning briefing reads item history before resurfacing decisions, and support-ticket tools match the live backend contract.
21
+ Version `7.38.0` is the current packaged-runtime line. Minor release over v7.37.4 - closeout integrity and operator-facing discipline: proactive status on long opaque work, per-item verified state on batch closeouts, decision-readable first responses, an automatic production change ledger across a wider mutation surface, new closure/evidence/cost-secret audit modules, and a correction-learning gate that never blocks mid-work. New behavioral rules ship in shadow/log mode by default.
22
22
 
23
- Previously in `7.37.3`: patch release over v7.37.2 - release pipeline timeout hardening: the Brain publish workflow keeps the full pre-publish pytest gate, but now gives slow GitHub runners enough time to finish the suite and release readiness before public-channel publication.
23
+ Previously in `7.37.4`: patch release over v7.37.3 - product-gap reporting, stale briefing noise, and opportunity loops: Deep Sleep reports recurring NEXO product gaps through sanitized Desktop support tickets, self-audit preserves closed internal opportunities, the morning briefing reads item history before resurfacing decisions, and support-ticket tools match the live backend contract.
24
24
 
25
25
  Previously in `7.37.2`: patch release over v7.37.1 - runtime shutdown and CI stability: session keepalive writers now stop before the shared SQLite connection closes, SQLite close is serialized under the write lock, and the full Brain test workflow has enough time to finish instead of cancelling slow-but-valid runs. The v7.37.2 tag attempt did not publish npm/GitHub release artifacts; v7.37.3 is the public line.
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.37.4",
3
+ "version": "7.38.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ """Retrospective closure audit for uncaptured promises and corrections."""
4
+
5
+ import re
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Callable, Iterable, Literal
8
+
9
+
10
+ SignalKind = Literal["promise", "correction"]
11
+ CaptureCheck = Callable[[str], bool]
12
+
13
+ _PROMISE_PATTERNS: tuple[re.Pattern[str], ...] = (
14
+ re.compile(r"\b(?:lo\s+dejo\s+registrad[oa]|queda\s+registrad[oa])\b", re.I),
15
+ re.compile(r"\b(?:luego|manana|ma[nñ]ana|cuando\s+cierre|al\s+cerrar)\b", re.I),
16
+ re.compile(r"\b(?:te\s+aviso|lo\s+retomo|queda\s+pendiente)\b", re.I),
17
+ re.compile(r"\b(?:I(?:'ll| will)\s+(?:record|follow up|do|check)|later|tomorrow)\b", re.I),
18
+ )
19
+
20
+ _CORRECTION_PATTERNS: tuple[re.Pattern[str], ...] = (
21
+ re.compile(r"\b(?:no[, ]+|eso\s+no|te\s+equivocas|est[aá]\s+mal)\b", re.I),
22
+ re.compile(r"\b(?:corrige|correcci[oó]n|no\s+es\s+eso|as[ií]\s+no)\b", re.I),
23
+ re.compile(r"\b(?:wrong|incorrect|that'?s\s+not|you\s+missed)\b", re.I),
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ClosureSignal:
29
+ kind: SignalKind
30
+ text: str
31
+ matched: str
32
+ persisted: bool
33
+ debt: bool
34
+ recommended_action: str
35
+ reason: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ClosureAudit:
40
+ ok: bool
41
+ debt_count: int
42
+ signals: list[ClosureSignal]
43
+
44
+ def to_dict(self) -> dict:
45
+ return {
46
+ "ok": self.ok,
47
+ "debt_count": self.debt_count,
48
+ "signals": [asdict(signal) for signal in self.signals],
49
+ }
50
+
51
+
52
+ def audit_closure(
53
+ assistant_text: str,
54
+ user_text: str = "",
55
+ *,
56
+ has_followup_for: CaptureCheck | None = None,
57
+ has_learning_for: CaptureCheck | None = None,
58
+ brain_down: bool = False,
59
+ ) -> ClosureAudit:
60
+ """Find promises/corrections that were not persisted before closure.
61
+
62
+ The audit is fail-closed: if persistence cannot be checked, the signal is
63
+ surfaced as explicit debt instead of being silently ignored.
64
+ """
65
+
66
+ signals: list[ClosureSignal] = []
67
+ for text, patterns, kind in (
68
+ (assistant_text, _PROMISE_PATTERNS, "promise"),
69
+ (user_text, _CORRECTION_PATTERNS, "correction"),
70
+ ):
71
+ for matched in _matches(text, patterns):
72
+ signals.append(
73
+ _evaluate_signal(
74
+ kind=kind,
75
+ text=text,
76
+ matched=matched,
77
+ checker=has_followup_for if kind == "promise" else has_learning_for,
78
+ brain_down=brain_down,
79
+ )
80
+ )
81
+
82
+ debt_count = sum(1 for signal in signals if signal.debt)
83
+ return ClosureAudit(ok=debt_count == 0, debt_count=debt_count, signals=signals)
84
+
85
+
86
+ def _matches(text: str, patterns: Iterable[re.Pattern[str]]) -> list[str]:
87
+ clean = text or ""
88
+ found: list[str] = []
89
+ seen: set[str] = set()
90
+ for pattern in patterns:
91
+ for match in pattern.finditer(clean):
92
+ value = match.group(0).strip()
93
+ key = value.lower()
94
+ if key not in seen:
95
+ seen.add(key)
96
+ found.append(value)
97
+ return found
98
+
99
+
100
+ def _evaluate_signal(
101
+ *,
102
+ kind: SignalKind,
103
+ text: str,
104
+ matched: str,
105
+ checker: CaptureCheck | None,
106
+ brain_down: bool,
107
+ ) -> ClosureSignal:
108
+ action = "create_followup" if kind == "promise" else "create_learning"
109
+ if brain_down:
110
+ return ClosureSignal(kind, text, matched, False, True, "mark_debt", "brain_down")
111
+ if checker is None:
112
+ return ClosureSignal(kind, text, matched, False, True, action, "missing_capture_check")
113
+ try:
114
+ persisted = bool(checker(text))
115
+ except Exception as exc: # pragma: no cover - reason asserted without type coupling
116
+ return ClosureSignal(
117
+ kind,
118
+ text,
119
+ matched,
120
+ False,
121
+ True,
122
+ action,
123
+ f"capture_check_failed:{type(exc).__name__}",
124
+ )
125
+ return ClosureSignal(
126
+ kind,
127
+ text,
128
+ matched,
129
+ persisted,
130
+ not persisted,
131
+ "none" if persisted else action,
132
+ "captured" if persisted else "not_captured",
133
+ )
134
+
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ """Daily cost/secret sweep queue builder."""
4
+
5
+ import glob
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Iterable
13
+
14
+ try:
15
+ from evidence_ledger import _SECRET_PATTERNS
16
+ except Exception: # pragma: no cover
17
+ _SECRET_PATTERNS = (
18
+ re.compile(r"(token\s*[=:]\s*)[^\s'\"]{8,}", re.I),
19
+ re.compile(r"(password\s*[=:]\s*)[^\s'\"]{6,}", re.I),
20
+ re.compile(r"(api[_-]?key\s*[=:]\s*)[^\s'\"]{8,}", re.I),
21
+ )
22
+
23
+
24
+ _CATEGORY_RULES: tuple[tuple[str, re.Pattern[str], int, int, str], ...] = (
25
+ ("billing_exhausted", re.compile(r"\b(billing|saldo|quota|cuota).{0,40}\b(agot|exhaust|insufficient|sin saldo)", re.I), 3, 1, "revisar billing y recarga"),
26
+ ("pasted_key", re.compile(r"\b(sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|AIza[A-Za-z0-9_-]{30,})\b"), 2, 3, "rotar clave pegada y mover a gestor"),
27
+ ("bridge_token", re.compile(r"\b(bridge|mcp|internal).{0,30}\b(token|api[_-]?key)\b", re.I), 2, 2, "rotar token puente y revisar scope"),
28
+ ("env_secret", re.compile(r"\b(env|\.env|log|stdout|trace).{0,30}\b(token|password|secret|api[_-]?key)\b", re.I), 2, 3, "mover secreto fuera de env/logs expuestos"),
29
+ ("rotation_pending", re.compile(r"(?=.*\b(rotar|rotation|comprometid|expuest|leak|public)\b)(?=.*\b(token|clave|secret|password|credencial|credential|api[_-]?key)\b)", re.I | re.S), 2, 2, "cerrar rotacion con evidencia"),
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SweepItem:
35
+ category: str
36
+ source: str
37
+ summary: str
38
+ economic_impact: int
39
+ exposure: int
40
+ priority: int
41
+ recommendation: str
42
+
43
+
44
+ def build_sweep_queue(records: Iterable[dict]) -> list[SweepItem]:
45
+ """Return one prioritized queue sorted by economic impact x exposure."""
46
+
47
+ items: list[SweepItem] = []
48
+ for record in records:
49
+ text = str(record.get("text") or record.get("summary") or "")
50
+ source = str(record.get("source") or "unknown")
51
+ for category, pattern, economic, exposure, recommendation in _CATEGORY_RULES:
52
+ if not pattern.search(text):
53
+ continue
54
+ item = SweepItem(
55
+ category=category,
56
+ source=source,
57
+ summary=redact_secrets(text)[:500],
58
+ economic_impact=int(record.get("economic_impact") or economic),
59
+ exposure=int(record.get("exposure") or exposure),
60
+ priority=int(record.get("economic_impact") or economic) * int(record.get("exposure") or exposure),
61
+ recommendation=recommendation,
62
+ )
63
+ items.append(item)
64
+ break
65
+ return sorted(items, key=lambda item: (-item.priority, item.category, item.source))
66
+
67
+
68
+ def collect_text_sources(paths: Iterable[str]) -> list[dict]:
69
+ records: list[dict] = []
70
+ for pattern in paths:
71
+ for filename in glob.glob(pattern):
72
+ path = Path(filename)
73
+ if not path.is_file():
74
+ continue
75
+ try:
76
+ text = path.read_text(errors="replace")
77
+ except OSError:
78
+ continue
79
+ records.append({"source": str(path), "text": text[:20000]})
80
+ return records
81
+
82
+
83
+ def run_sweep(*, records: Iterable[dict] = (), paths: Iterable[str] = ()) -> dict:
84
+ all_records = list(records)
85
+ all_records.extend(collect_text_sources(paths))
86
+ queue = build_sweep_queue(all_records)
87
+ return {
88
+ "generated_at": int(time.time()),
89
+ "count": len(queue),
90
+ "queue": [asdict(item) for item in queue],
91
+ }
92
+
93
+
94
+ def default_paths(nexo_home: str | None = None) -> list[str]:
95
+ home = Path(nexo_home or os.environ.get("NEXO_HOME") or "~/.nexo").expanduser()
96
+ return [
97
+ str(home / "runtime" / "logs" / "*.log"),
98
+ str(home / "runtime" / "operations" / "*.jsonl"),
99
+ str(home / ".env"),
100
+ ]
101
+
102
+
103
+ def append_jsonl_report(report: dict, output_path: str) -> None:
104
+ path = Path(output_path)
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ with path.open("a", encoding="utf-8") as fh:
107
+ fh.write(json.dumps(report, ensure_ascii=False, sort_keys=True) + "\n")
108
+
109
+
110
+ def redact_secrets(text: str) -> str:
111
+ redacted = str(text or "")
112
+ for pattern in _SECRET_PATTERNS:
113
+ redacted = pattern.sub(_redact_match, redacted)
114
+ return redacted
115
+
116
+
117
+ def _redact_match(match: re.Match[str]) -> str:
118
+ if match.lastindex:
119
+ return f"{match.group(1)}[REDACTED]"
120
+ return "[REDACTED]"
@@ -16,6 +16,7 @@ from urllib.parse import quote, unquote
16
16
 
17
17
  KEYRING_SERVICE = "com.nexo.email"
18
18
  KEYRING_MARKER_PREFIX = "keyring://"
19
+ HEADLESS_FALLBACK_SERVICE_SUFFIX = "::headless-fallback"
19
20
 
20
21
 
21
22
  def _db():
@@ -42,6 +43,10 @@ def _account_name(service: str, key: str) -> str:
42
43
  return f"{service}:{key}"
43
44
 
44
45
 
46
+ def _headless_fallback_ref(service: str, key: str) -> tuple[str, str]:
47
+ return f"{service}{HEADLESS_FALLBACK_SERVICE_SUFFIX}", key
48
+
49
+
45
50
  def _keyring_module():
46
51
  try:
47
52
  return importlib.import_module("keyring")
@@ -78,6 +83,32 @@ def _write_db_value(service: str, key: str, value: str, notes: str) -> None:
78
83
  conn.commit()
79
84
 
80
85
 
86
+ def _delete_db_value(service: str, key: str) -> None:
87
+ conn = _db()
88
+ conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
89
+ conn.commit()
90
+
91
+
92
+ def _write_headless_fallback(service: str, key: str, value: str, notes: str) -> None:
93
+ fallback_service, fallback_key = _headless_fallback_ref(service, key)
94
+ _write_db_value(
95
+ fallback_service,
96
+ fallback_key,
97
+ value,
98
+ notes + " (headless fallback mirror)",
99
+ )
100
+
101
+
102
+ def _read_headless_fallback(service: str, key: str) -> str:
103
+ fallback_service, fallback_key = _headless_fallback_ref(service, key)
104
+ return _read_stored_value(fallback_service, fallback_key)
105
+
106
+
107
+ def _delete_headless_fallback(service: str, key: str) -> None:
108
+ fallback_service, fallback_key = _headless_fallback_ref(service, key)
109
+ _delete_db_value(fallback_service, fallback_key)
110
+
111
+
81
112
  def store_email_credential(service: str, key: str, value: str, notes: str = "email account password") -> str:
82
113
  """Store an email password and return the SQLite value written.
83
114
 
@@ -96,6 +127,7 @@ def store_email_credential(service: str, key: str, value: str, notes: str = "ema
96
127
  if keyring is not None:
97
128
  try:
98
129
  keyring.set_password(KEYRING_SERVICE, _account_name(service, key), value)
130
+ _write_headless_fallback(service, key, value, notes)
99
131
  stored = _marker(service, key)
100
132
  _write_db_value(service, key, stored, notes + " (stored in system keyring)")
101
133
  return stored
@@ -118,12 +150,13 @@ def read_email_credential(service: str, key: str) -> str:
118
150
 
119
151
  keyring = _keyring_module()
120
152
  if keyring is None:
121
- return ""
153
+ return _read_headless_fallback(*parsed)
122
154
  try:
123
155
  value = keyring.get_password(KEYRING_SERVICE, _account_name(*parsed))
124
156
  except Exception:
125
- return ""
126
- return str(value or "")
157
+ value = ""
158
+ value = str(value or "")
159
+ return value or _read_headless_fallback(*parsed)
127
160
 
128
161
 
129
162
  def delete_email_credential(service: str, key: str) -> None:
@@ -140,9 +173,8 @@ def delete_email_credential(service: str, key: str) -> None:
140
173
  except Exception:
141
174
  pass
142
175
 
143
- conn = _db()
144
- conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
145
- conn.commit()
176
+ _delete_db_value(service, key)
177
+ _delete_headless_fallback(service, key)
146
178
 
147
179
 
148
180
  def is_keyring_marker(value: str) -> bool:
@@ -31,12 +31,16 @@ except ImportError: # pragma: no cover — fallback for editable installs with
31
31
  try:
32
32
  from r14_correction_learning import (
33
33
  detect_correction as _detect_correction,
34
+ detect_accepted_correction as _detect_accepted_correction,
34
35
  INJECTION_PROMPT_TEMPLATE as _R14_PROMPT,
36
+ ACCEPTANCE_INJECTION_PROMPT_TEMPLATE as _R14_ACCEPTANCE_PROMPT,
35
37
  DEFAULT_WINDOW_TOOL_CALLS as _R14_WINDOW,
36
38
  )
37
39
  except ImportError: # pragma: no cover
38
40
  _detect_correction = None # type: ignore
41
+ _detect_accepted_correction = None # type: ignore
39
42
  _R14_PROMPT = "" # type: ignore
43
+ _R14_ACCEPTANCE_PROMPT = "" # type: ignore
40
44
  _R14_WINDOW = 3
41
45
 
42
46
  try:
@@ -981,7 +985,78 @@ class HeadlessEnforcer:
981
985
  self._r14_correction_seen_for_turn = False
982
986
  self._r14_correction_text = ""
983
987
 
984
- def on_assistant_text(self, text: str, *, declared_detector=None, has_open_task=None):
988
+ def _learning_add_seen_for_current_turn(self) -> bool:
989
+ current_turn = int(self.user_message_count or 0)
990
+ return any(
991
+ self._tool_user_message_index.get(tool, -1) >= current_turn
992
+ for tool in ("nexo_learning_add",)
993
+ )
994
+
995
+ def _record_missing_learning_after_accepted_correction(self) -> None:
996
+ if not self._session_id:
997
+ return
998
+ try:
999
+ from db import create_protocol_debt, list_protocol_debts, record_session_correction_requirement # type: ignore
1000
+
1001
+ record_session_correction_requirement(
1002
+ self._session_id,
1003
+ self._r14_correction_text,
1004
+ source="r14_accepted_correction_gate",
1005
+ )
1006
+ existing = list_protocol_debts(
1007
+ status="open",
1008
+ session_id=self._session_id,
1009
+ debt_type="missing_learning_after_correction",
1010
+ limit=1,
1011
+ )
1012
+ if not existing:
1013
+ create_protocol_debt(
1014
+ self._session_id,
1015
+ "missing_learning_after_correction",
1016
+ severity="error",
1017
+ evidence=(
1018
+ "R14 detected that the assistant accepted a user correction "
1019
+ "before nexo_learning_add was called."
1020
+ ),
1021
+ )
1022
+ except Exception:
1023
+ pass
1024
+
1025
+ def _check_r14_accepted_correction(self, text: str, *, accepted_detector=None) -> None:
1026
+ if not self._r14_correction_seen_for_turn:
1027
+ return
1028
+ if self._learning_add_seen_for_current_turn():
1029
+ return
1030
+ detector = accepted_detector if accepted_detector is not None else _detect_accepted_correction
1031
+ if detector is None:
1032
+ return
1033
+ mode = self._guardian_rule_mode("R14_correction_learning")
1034
+ if mode == "off":
1035
+ return
1036
+ try:
1037
+ accepted = bool(
1038
+ detector(
1039
+ text or "",
1040
+ correction_text=self._r14_correction_text,
1041
+ )
1042
+ )
1043
+ except Exception as exc: # noqa: BLE001
1044
+ _logger.warning("R14 accepted-correction detector failed (%s); staying silent", exc)
1045
+ accepted = False
1046
+ if not accepted:
1047
+ return
1048
+ if mode == "shadow":
1049
+ _logger.info("[R14 SHADOW] would block accepted correction without learning_add")
1050
+ return
1051
+ self._enqueue(
1052
+ _R14_ACCEPTANCE_PROMPT,
1053
+ "r14:accepted-correction-without-learning",
1054
+ rule_id="R14_correction_learning",
1055
+ )
1056
+ self._record_missing_learning_after_accepted_correction()
1057
+ _logger.info("[R14 %s] enqueued accepted-correction learning gate", mode.upper())
1058
+
1059
+ def on_assistant_text(self, text: str, *, declared_detector=None, has_open_task=None, accepted_correction_detector=None):
985
1060
  """R16 — scan assistant message for done-claim with open protocol_task.
986
1061
 
987
1062
  Args:
@@ -1005,6 +1080,10 @@ class HeadlessEnforcer:
1005
1080
  self._check_jargon_text(text, tag="r26:first-response-jargon")
1006
1081
  self._check_execute_before_ask(text)
1007
1082
  self._check_capability_denial_requires_reality(text)
1083
+ self._check_r14_accepted_correction(
1084
+ text,
1085
+ accepted_detector=accepted_correction_detector,
1086
+ )
1008
1087
  if _detect_declared_done is None:
1009
1088
  return
1010
1089
  mode = self._guardian_rule_mode("R16_declared_done")
@@ -1362,6 +1441,7 @@ class HeadlessEnforcer:
1362
1441
  r"\bgcloud\s+builds\s+(?:submit|triggers\s+run)\b",
1363
1442
  r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
1364
1443
  r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
1444
+ r"\b(?:alembic\s+upgrade|prisma\s+migrate\s+deploy|sequelize\s+db:migrate|knex\s+migrate:latest|rails\s+db:migrate|python(?:3)?\s+manage\.py\s+migrate|php\s+artisan\s+migrate)\b",
1365
1445
  r"\b(?:whmapi1|uapi|cpapi2)\b",
1366
1446
  r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
1367
1447
  r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ """Require live evidence before root-cause claims in sensitive domains."""
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable, Iterable, Literal
8
+
9
+ try: # Reuse the existing ledger when available, but keep tests injectable.
10
+ from evidence_ledger import search_evidence as _ledger_search
11
+ except Exception: # pragma: no cover
12
+ _ledger_search = None
13
+
14
+
15
+ Verdict = Literal["pass", "needs_evidence", "not_applicable"]
16
+ SearchFn = Callable[[str], Iterable[Any]]
17
+
18
+ _DOMAIN_PATTERNS: dict[str, tuple[re.Pattern[str], ...]] = {
19
+ "provider": (re.compile(r"\b(provider|proveedor|cloudflare|openai|shopify|stripe|google)\b", re.I),),
20
+ "quota": (re.compile(r"\b(quota|cuota|limit|limite|l[ií]mite|billing agotado)\b", re.I),),
21
+ "payment": (re.compile(r"\b(payment|pago|billing|facturaci[oó]n|tarjeta|stripe)\b", re.I),),
22
+ "token": (re.compile(r"\b(token|api[_ -]?key|clave|secret|secreto|credential|credencial)\b", re.I),),
23
+ "firewall": (re.compile(r"\b(firewall|csf|waf|iptables|cloud armor|bloque[oó])\b", re.I),),
24
+ "production": (re.compile(r"\b(prod|producci[oó]n|live|deploy|cloud run|server|servidor)\b", re.I),),
25
+ "customer_impact": (re.compile(r"\b(cliente|customer|venta|pedido|impacto|ca[ií]da|503|5xx)\b", re.I),),
26
+ }
27
+
28
+ _CAUSE_PATTERN = re.compile(
29
+ r"\b(causa|root cause|porque|por\s+que|se debe a|caused by|culpa|fall[oó]\s+por|bloqueado por)\b",
30
+ re.I,
31
+ )
32
+ _HEDGED_PATTERN = re.compile(
33
+ r"\b(podr[ií]a|parece|posible|hip[oó]tesis|hay que verificar|no confirmado|maybe|might|could)\b|\?$",
34
+ re.I,
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class EvidenceMatrixResult:
40
+ verdict: Verdict
41
+ domains: list[str]
42
+ evidence_count: int
43
+ reason: str
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ return {
47
+ "verdict": self.verdict,
48
+ "domains": self.domains,
49
+ "evidence_count": self.evidence_count,
50
+ "reason": self.reason,
51
+ }
52
+
53
+
54
+ def validate_claim(
55
+ text: str,
56
+ *,
57
+ evidence_refs: Iterable[Any] | None = None,
58
+ search: SearchFn | None = None,
59
+ ) -> EvidenceMatrixResult:
60
+ """Validate a sensitive causal claim against live evidence pointers."""
61
+
62
+ claim = text or ""
63
+ domains = _domains_for(claim)
64
+ if not domains or not _CAUSE_PATTERN.search(claim) or _HEDGED_PATTERN.search(claim):
65
+ return EvidenceMatrixResult("not_applicable", domains, 0, "non_causal_or_hedged")
66
+
67
+ refs = [ref for ref in (evidence_refs or []) if ref]
68
+ if refs:
69
+ return EvidenceMatrixResult("pass", domains, len(refs), "explicit_evidence_refs")
70
+
71
+ search_fn = search or _ledger_search
72
+ if search_fn is not None:
73
+ try:
74
+ hits = list(search_fn(claim) or [])
75
+ except Exception:
76
+ hits = []
77
+ if hits:
78
+ return EvidenceMatrixResult("pass", domains, len(hits), "ledger_evidence")
79
+
80
+ return EvidenceMatrixResult(
81
+ "needs_evidence",
82
+ domains,
83
+ 0,
84
+ "cita un log, query, endpoint, commit o reproduccion antes de afirmar causa raiz",
85
+ )
86
+
87
+
88
+ def _domains_for(text: str) -> list[str]:
89
+ return [
90
+ domain
91
+ for domain, patterns in _DOMAIN_PATTERNS.items()
92
+ if any(pattern.search(text or "") for pattern in patterns)
93
+ ]
94
+