superlocalmemory 4.0.3 → 4.0.5

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +19 -13
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +5 -4
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +7 -6
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-graph/SKILL.md +3 -2
  30. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-profile/SKILL.md +5 -4
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +1 -1
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/brain/__init__.py +5 -0
  41. package/src/superlocalmemory/brain/truth.py +348 -0
  42. package/src/superlocalmemory/cli/commands.py +82 -25
  43. package/src/superlocalmemory/cli/main.py +12 -0
  44. package/src/superlocalmemory/core/context_cache.py +58 -1
  45. package/src/superlocalmemory/core/mutations.py +155 -25
  46. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  47. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  48. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  49. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  50. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  51. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  52. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +185 -0
  53. package/src/superlocalmemory/learning/database.py +2 -1
  54. package/src/superlocalmemory/mcp/profiles.py +25 -7
  55. package/src/superlocalmemory/mcp/server.py +7 -2
  56. package/src/superlocalmemory/mcp/tools_brain.py +138 -9
  57. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  58. package/src/superlocalmemory/retrieval/engine.py +7 -10
  59. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  60. package/src/superlocalmemory/server/routes/brain.py +21 -1
  61. package/src/superlocalmemory/server/routes/memories.py +129 -3
  62. package/src/superlocalmemory/storage/_migration_internals.py +8 -0
  63. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  64. package/src/superlocalmemory/storage/agent_experience.py +26 -4
  65. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  66. package/src/superlocalmemory/storage/database.py +194 -24
  67. package/src/superlocalmemory/storage/external_evidence.py +359 -0
  68. package/src/superlocalmemory/storage/migration_runner.py +12 -0
  69. package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
  70. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  71. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  72. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  73. package/src/superlocalmemory/ui/js/brain.js +43 -7
  74. package/src/superlocalmemory/ui/js/od-brain.js +44 -19
@@ -569,6 +569,62 @@ class DatabaseManager:
569
569
  created_at=d["created_at"],
570
570
  )
571
571
 
572
+ def insert_fact_immutable(self, fact: AtomicFact) -> str:
573
+ """Insert one known-new fact without content deduplication or replacement.
574
+
575
+ Reviewed correction successors require a caller-chosen immutable
576
+ identity. Unlike normal remember ingestion, equal content must not
577
+ reinforce an existing row, and an occupied ID must abort the enclosing
578
+ transaction rather than overwrite history.
579
+ """
580
+ scope = getattr(fact, "scope", None) or "personal"
581
+ shared = _jd(getattr(fact, "shared_with", None))
582
+ self.execute(
583
+ """INSERT INTO atomic_facts
584
+ (fact_id, memory_id, profile_id, content, fact_type,
585
+ entities_json, canonical_entities_json,
586
+ observation_date, referenced_date, interval_start, interval_end,
587
+ confidence, importance, evidence_count, access_count,
588
+ source_turn_ids_json, session_id,
589
+ embedding, fisher_mean, fisher_variance,
590
+ lifecycle, langevin_position,
591
+ emotional_valence, emotional_arousal, signal_type, created_at,
592
+ scope, shared_with)
593
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
594
+ (
595
+ fact.fact_id,
596
+ fact.memory_id,
597
+ fact.profile_id,
598
+ fact.content,
599
+ fact.fact_type.value,
600
+ json.dumps(fact.entities),
601
+ json.dumps(fact.canonical_entities),
602
+ fact.observation_date,
603
+ fact.referenced_date,
604
+ fact.interval_start,
605
+ fact.interval_end,
606
+ fact.confidence,
607
+ fact.importance,
608
+ fact.evidence_count,
609
+ fact.access_count,
610
+ json.dumps(fact.source_turn_ids),
611
+ fact.session_id,
612
+ _jd(fact.embedding),
613
+ _jd(fact.fisher_mean),
614
+ _jd(fact.fisher_variance),
615
+ fact.lifecycle.value,
616
+ _jd(fact.langevin_position),
617
+ fact.emotional_valence,
618
+ fact.emotional_arousal,
619
+ fact.signal_type.value,
620
+ fact.created_at,
621
+ scope,
622
+ shared,
623
+ ),
624
+ )
625
+ self.store_temporal_validity(fact.fact_id, fact.profile_id)
626
+ return fact.fact_id
627
+
572
628
  def set_pinned(self, fact_id: str, pinned: bool) -> None:
573
629
  """Set or clear the pinned flag on a fact (v3.4.65 core-memory)."""
574
630
  self.execute(
@@ -604,7 +660,23 @@ class DatabaseManager:
604
660
  "ORDER BY importance DESC",
605
661
  (*params,),
606
662
  )
607
- return [self._row_to_fact(r) for r in rows]
663
+ facts = [self._row_to_fact(r) for r in rows]
664
+ if not facts:
665
+ return facts
666
+ try:
667
+ blocked = self.get_nonapplied_correction_successor_ids(
668
+ [fact.fact_id for fact in facts],
669
+ profile_id,
670
+ include_global=include_global,
671
+ include_shared=include_shared,
672
+ )
673
+ except Exception as exc:
674
+ logger.warning("Pinned correction admission lookup failed: %s", exc)
675
+ return []
676
+ if not isinstance(blocked, set):
677
+ logger.warning("Pinned correction admission returned malformed data")
678
+ return []
679
+ return [fact for fact in facts if fact.fact_id not in blocked]
608
680
 
609
681
  def _has_archive_status(self) -> bool:
610
682
  """Whether atomic_facts carries the M011 ``archive_status`` column.
@@ -1647,12 +1719,14 @@ class DatabaseManager:
1647
1719
  def invalidate_fact_temporal(
1648
1720
  self, fact_id: str, invalidated_by: str,
1649
1721
  invalidation_reason: str,
1722
+ *,
1723
+ event_valid_until: str | None = None,
1650
1724
  ) -> None:
1651
1725
  """Mark a fact as invalidated, preserving bi-temporal independence.
1652
1726
 
1653
- - valid_until (event-time): when the fact ceased to be true in the
1654
- real world. Sourced from the fact's referenced_date so the
1655
- real-world boundary is preserved, not overwritten with wall-clock time.
1727
+ - valid_until (event-time): changed only when a reviewer supplies an
1728
+ independently validated real-world boundary. Review time, source
1729
+ timestamps, and a detector's conclusion are not a valid substitute.
1656
1730
  - system_expired_at (transaction-time): when the system learned the
1657
1731
  fact was invalid — always set to now.
1658
1732
 
@@ -1666,29 +1740,12 @@ class DatabaseManager:
1666
1740
  from datetime import datetime as _dt
1667
1741
  now = _dt.now(UTC).isoformat()
1668
1742
 
1669
- # Resolve the event-time boundary from the fact's referenced_date.
1670
- # Falls back to the current valid_until (which may already be set),
1671
- # and ultimately to now if neither is available.
1672
- fact_rows = self.execute(
1673
- "SELECT referenced_date FROM atomic_facts WHERE fact_id = ?",
1674
- (fact_id,),
1675
- )
1676
- referenced_date = dict(fact_rows[0]).get("referenced_date") if fact_rows else None
1677
-
1678
- tv_rows = self.execute(
1679
- "SELECT valid_until FROM fact_temporal_validity WHERE fact_id = ?",
1680
- (fact_id,),
1681
- )
1682
- existing_valid_until = dict(tv_rows[0]).get("valid_until") if tv_rows else None
1683
-
1684
- valid_until = referenced_date or existing_valid_until or now
1685
-
1686
1743
  self.execute(
1687
1744
  "UPDATE fact_temporal_validity "
1688
- "SET valid_until = ?, system_expired_at = ?, "
1745
+ "SET valid_until = COALESCE(?, valid_until), system_expired_at = ?, "
1689
1746
  " invalidated_by = ?, invalidation_reason = ? "
1690
- "WHERE fact_id = ?",
1691
- (valid_until, now, invalidated_by, invalidation_reason, fact_id),
1747
+ "WHERE fact_id = ? AND system_expired_at IS NULL",
1748
+ (event_valid_until, now, invalidated_by, invalidation_reason, fact_id),
1692
1749
  )
1693
1750
 
1694
1751
  def get_valid_facts(self, profile_id: str) -> list[str]:
@@ -1804,6 +1861,119 @@ class DatabaseManager:
1804
1861
  invalid.add(dict(r)["fact_id"])
1805
1862
  return invalid
1806
1863
 
1864
+ def get_nonapplied_correction_successor_ids(
1865
+ self,
1866
+ fact_ids: list[str],
1867
+ profile_id: str,
1868
+ *,
1869
+ include_global: bool = False,
1870
+ include_shared: bool = False,
1871
+ ) -> set[str]:
1872
+ """Return candidate successors that are not current review truth.
1873
+
1874
+ M042 is optional for older databases. Its absence is safe because
1875
+ canonical proposal never commits a successor unless the same
1876
+ transaction also writes M042. Once present, a read failure must reach
1877
+ the retrieval fail-closed boundary rather than be converted to empty.
1878
+ """
1879
+ if not fact_ids:
1880
+ return set()
1881
+ present = self.execute(
1882
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
1883
+ )
1884
+ if not present:
1885
+ return set()
1886
+ scope_where, scope_params = _scope_where(
1887
+ profile_id,
1888
+ include_global=include_global,
1889
+ include_shared=include_shared,
1890
+ prefix="f",
1891
+ )
1892
+ inadmissible: set[str] = set()
1893
+ for start in range(0, len(fact_ids), 900):
1894
+ batch = fact_ids[start:start + 900]
1895
+ placeholders = ",".join("?" for _ in batch)
1896
+ rows = self.execute(
1897
+ "SELECT c.successor_fact_id FROM correction_cases c "
1898
+ "JOIN atomic_facts f ON f.fact_id=c.successor_fact_id "
1899
+ f"WHERE c.successor_fact_id IN ({placeholders}) AND {scope_where} "
1900
+ "AND c.profile_id=f.profile_id "
1901
+ "AND c.status IN ('proposed', 'rejected', 'rolled_back')",
1902
+ (*batch, *scope_params),
1903
+ )
1904
+ inadmissible.update(str(row["successor_fact_id"]) for row in rows)
1905
+ return inadmissible
1906
+
1907
+ def get_correction_inadmissible_fact_ids(
1908
+ self,
1909
+ fact_ids: list[str],
1910
+ profile_id: str,
1911
+ as_of: str | None = None,
1912
+ *,
1913
+ include_global: bool = False,
1914
+ include_shared: bool = False,
1915
+ ) -> set[str]:
1916
+ """Return current-lifecycle exclusions with one bounded SQLite read.
1917
+
1918
+ Recall needs both sides of reviewed correction truth: an expired
1919
+ predecessor and a successor whose case is not applied. The older
1920
+ public helpers preserve their focused contracts, but invoking them
1921
+ consecutively opened two SQLite connections on every candidate stage.
1922
+ This read-model helper uses one connection and one UNION query while
1923
+ retaining the same profile/scope and historical ``as_of`` semantics.
1924
+ It is intentionally read-only and does not cache lifecycle state.
1925
+ """
1926
+ if not fact_ids:
1927
+ return set()
1928
+ scope_where, scope_params = _scope_where(
1929
+ profile_id,
1930
+ include_global=include_global,
1931
+ include_shared=include_shared,
1932
+ prefix="f",
1933
+ )
1934
+ inadmissible: set[str] = set()
1935
+ with self.raw_connection() as conn:
1936
+ correction_table = conn.execute(
1937
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='correction_cases'"
1938
+ ).fetchone()
1939
+ for start in range(0, len(fact_ids), 900):
1940
+ batch = fact_ids[start:start + 900]
1941
+ placeholders = ",".join("?" for _ in batch)
1942
+ temporal_conditions = (
1943
+ "AND tv.system_expired_at IS NOT NULL "
1944
+ + ("AND tv.system_expired_at <= ?" if as_of is not None else "")
1945
+ )
1946
+ temporal_sql = (
1947
+ "SELECT tv.fact_id AS fact_id "
1948
+ "FROM fact_temporal_validity tv "
1949
+ "JOIN atomic_facts f ON f.fact_id=tv.fact_id "
1950
+ f"WHERE tv.fact_id IN ({placeholders}) AND {scope_where} "
1951
+ "AND tv.profile_id=f.profile_id "
1952
+ f"{temporal_conditions}"
1953
+ )
1954
+ temporal_params: tuple[Any, ...] = (
1955
+ *batch,
1956
+ *scope_params,
1957
+ *((as_of,) if as_of is not None else ()),
1958
+ )
1959
+ if correction_table is None:
1960
+ rows = conn.execute(temporal_sql, temporal_params).fetchall()
1961
+ else:
1962
+ correction_sql = (
1963
+ "SELECT c.successor_fact_id AS fact_id "
1964
+ "FROM correction_cases c "
1965
+ "JOIN atomic_facts f ON f.fact_id=c.successor_fact_id "
1966
+ f"WHERE c.successor_fact_id IN ({placeholders}) AND {scope_where} "
1967
+ "AND c.profile_id=f.profile_id "
1968
+ "AND c.status IN ('proposed', 'rejected', 'rolled_back')"
1969
+ )
1970
+ rows = conn.execute(
1971
+ f"{temporal_sql} UNION {correction_sql}",
1972
+ (*temporal_params, *batch, *scope_params),
1973
+ ).fetchall()
1974
+ inadmissible.update(str(row["fact_id"]) for row in rows)
1975
+ return inadmissible
1976
+
1807
1977
  def get_strict_temporal_inadmissible_fact_ids(
1808
1978
  self,
1809
1979
  fact_ids: list[str],
@@ -0,0 +1,359 @@
1
+ """Typed storage for versioned, observation-only MCP evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import sqlite3
9
+ import threading
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ from superlocalmemory.storage.agent_experience import (
16
+ _PROCESS_LOCKS,
17
+ _PROCESS_LOCKS_GUARD,
18
+ _PROFILE_GATES,
19
+ LearningWriteBusyError,
20
+ ProfileAdmissionError,
21
+ _ProfileAdmissionGate,
22
+ )
23
+
24
+ _CONTRACT = "bounded-loops.dev/slm-bridge/v1"
25
+ _SHA256 = re.compile(r"\Asha256:[a-f0-9]{64}\Z")
26
+ _IDENTIFIER = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
27
+ _RUN_STATES = frozenset({"SUCCEEDED", "FAILED", "HALTED", "CANCELLED", "EXPIRED"})
28
+ _OUTCOMES = frozenset({"SUCCEEDED", "FAILED", "CANCELLED"})
29
+ _MAX_NODES = 256
30
+ _MAX_ARTIFACTS_PER_NODE = 64
31
+ _MAX_ARTIFACTS_TOTAL = 2_048
32
+ _MAX_NODES_JSON_BYTES = 64 * 1024
33
+ _MAX_TIMESTAMP_BYTES = 128
34
+ _MAX_RECEIPT_SEQUENCE = (1 << 63) - 1
35
+ _INSERT = (
36
+ "INSERT INTO external_evidence_receipts (profile_id, contract_id, workspace_id, "
37
+ "run_ref, run_id, outcome, run_state, demonstration, "
38
+ "eligible_for_learning, terminal_at, graph_digest, plan_digest, "
39
+ "policy_digest, receipt_sequence, receipt_head_digest, receipt_trust, "
40
+ "nodes_json, artifact_digests_json, payload_sha256, observed_at) "
41
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
42
+ "ON CONFLICT(profile_id, contract_id, workspace_id, run_ref) DO NOTHING"
43
+ )
44
+
45
+
46
+ class ExternalEvidenceConflictError(ValueError):
47
+ """A stable external run address produced a different terminal receipt head."""
48
+
49
+
50
+ class ExternalEvidenceValidationError(ValueError):
51
+ """An external evidence document does not satisfy the public v1 contract."""
52
+
53
+
54
+ class ExternalEvidenceStore:
55
+ """Persist evidence without entering SLM's memory/recall lock domain."""
56
+
57
+ def __init__(self, path: str | Path, *, is_profile_active: Callable[[str], bool]) -> None:
58
+ self._path = Path(path)
59
+ self._is_profile_active = is_profile_active
60
+ resolved = str(self._path.resolve())
61
+ with _PROCESS_LOCKS_GUARD:
62
+ self._lock = _PROCESS_LOCKS.setdefault(resolved, threading.Lock())
63
+ self._gate = _PROFILE_GATES.setdefault(resolved, _ProfileAdmissionGate())
64
+
65
+ def record(self, payload: dict[str, Any]) -> bool:
66
+ _validate(payload)
67
+ profile_id = payload["profile_id"]
68
+ self._gate.admit(profile_id, self._is_profile_active)
69
+ digest = _payload_digest(payload)
70
+ deadline = time.monotonic() + 0.90
71
+ if not self._lock.acquire(timeout=0.90):
72
+ self._gate.release(profile_id)
73
+ raise LearningWriteBusyError("external evidence write deadline exceeded")
74
+ try:
75
+ while True:
76
+ conn: sqlite3.Connection | None = None
77
+ try:
78
+ conn = sqlite3.connect(str(self._path), timeout=0, isolation_level=None)
79
+ conn.row_factory = sqlite3.Row
80
+ conn.execute("PRAGMA journal_mode=WAL")
81
+ conn.execute("PRAGMA busy_timeout=0")
82
+ conn.execute("PRAGMA synchronous=NORMAL")
83
+ conn.execute("BEGIN IMMEDIATE")
84
+ _assert_profile_open(conn, profile_id)
85
+ row = _row(payload, digest)
86
+ cursor = conn.execute(_INSERT, row)
87
+ if cursor.rowcount:
88
+ conn.execute("COMMIT")
89
+ return True
90
+ existing = _get_conn(
91
+ conn,
92
+ profile_id,
93
+ payload["contract"],
94
+ payload["workspace_id"],
95
+ payload["run_ref"],
96
+ )
97
+ conn.execute("ROLLBACK")
98
+ if _payload_digest(existing) == digest:
99
+ return False
100
+ raise ExternalEvidenceConflictError(
101
+ "external run address has a different receipt head"
102
+ )
103
+ except sqlite3.OperationalError as exc:
104
+ if conn is not None and conn.in_transaction:
105
+ conn.execute("ROLLBACK")
106
+ busy = "locked" in str(exc).lower() or "busy" in str(exc).lower()
107
+ if not busy or time.monotonic() >= deadline:
108
+ if busy:
109
+ raise LearningWriteBusyError(
110
+ "external evidence write deadline exceeded"
111
+ ) from exc
112
+ raise
113
+ time.sleep(0.02)
114
+ finally:
115
+ if conn is not None:
116
+ conn.close()
117
+ finally:
118
+ self._lock.release()
119
+ self._gate.release(profile_id)
120
+
121
+ def get(self, profile_id: str, workspace_id: str, run_ref: str) -> dict[str, Any] | None:
122
+ conn = sqlite3.connect(f"{self._path.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
123
+ conn.row_factory = sqlite3.Row
124
+ try:
125
+ return _get_conn(conn, profile_id, _CONTRACT, workspace_id, run_ref)
126
+ finally:
127
+ conn.close()
128
+
129
+
130
+ def get_profile_external_evidence_summary(path: str | Path, profile_id: str) -> dict[str, Any]:
131
+ """Return indexed Living Brain totals without opening SLM's memory database."""
132
+ empty = {
133
+ "is_real": False,
134
+ "availability": "unavailable",
135
+ "total": 0,
136
+ "by_run_state": {},
137
+ "demonstrations": 0,
138
+ }
139
+ target = Path(path)
140
+ if not target.exists():
141
+ return empty
142
+ conn: sqlite3.Connection | None = None
143
+ try:
144
+ conn = sqlite3.connect(f"{target.resolve().as_uri()}?mode=ro", uri=True, timeout=0.5)
145
+ total = conn.execute(
146
+ "SELECT COUNT(*) FROM external_evidence_receipts WHERE profile_id=?", (profile_id,)
147
+ ).fetchone()[0]
148
+ demo = conn.execute(
149
+ "SELECT COUNT(*) FROM external_evidence_receipts "
150
+ "WHERE profile_id=? AND demonstration=1",
151
+ (profile_id,),
152
+ ).fetchone()[0]
153
+ rows = conn.execute(
154
+ "SELECT run_state, COUNT(*) FROM external_evidence_receipts "
155
+ "WHERE profile_id=? GROUP BY run_state",
156
+ (profile_id,),
157
+ ).fetchall()
158
+ except sqlite3.Error:
159
+ return empty
160
+ finally:
161
+ if conn is not None:
162
+ conn.close()
163
+ return {
164
+ "is_real": True,
165
+ "availability": "available",
166
+ "total": int(total),
167
+ "by_run_state": {str(k): int(v) for k, v in rows},
168
+ "demonstrations": int(demo),
169
+ "control_plane": "observation_only",
170
+ }
171
+
172
+
173
+ def _validate(payload: dict[str, Any]) -> None:
174
+ required = {
175
+ "contract",
176
+ "profile_id",
177
+ "workspace_id",
178
+ "run_ref",
179
+ "run_id",
180
+ "outcome",
181
+ "run_state",
182
+ "demonstration",
183
+ "eligible_for_learning",
184
+ "terminal_at",
185
+ "graph_digest",
186
+ "plan_digest",
187
+ "policy_digest",
188
+ "receipt",
189
+ "nodes",
190
+ }
191
+ if set(payload) != required:
192
+ raise ExternalEvidenceValidationError("external evidence fields do not match v1")
193
+ if payload["contract"] != _CONTRACT:
194
+ raise ExternalEvidenceValidationError("unsupported external evidence contract")
195
+ for name in ("profile_id", "run_ref", "run_id"):
196
+ if not isinstance(payload[name], str) or not _IDENTIFIER.match(payload[name]):
197
+ raise ExternalEvidenceValidationError(f"{name} must be a safe identifier")
198
+ for name in ("workspace_id", "graph_digest", "plan_digest", "policy_digest"):
199
+ if not isinstance(payload[name], str) or not _SHA256.match(payload[name]):
200
+ raise ExternalEvidenceValidationError(f"{name} must be a sha256 digest")
201
+ if payload["outcome"] not in _OUTCOMES or payload["run_state"] not in _RUN_STATES:
202
+ raise ExternalEvidenceValidationError("outcome or run_state is unsupported")
203
+ if payload["run_state"] == "SUCCEEDED" and payload["outcome"] != "SUCCEEDED":
204
+ raise ExternalEvidenceValidationError("SUCCEEDED run_state must keep its outcome")
205
+ if (
206
+ not isinstance(payload["terminal_at"], str)
207
+ or len(payload["terminal_at"].encode("utf-8")) > _MAX_TIMESTAMP_BYTES
208
+ ):
209
+ raise ExternalEvidenceValidationError("terminal_at must be an RFC3339 timestamp")
210
+ try:
211
+ datetime.fromisoformat(payload["terminal_at"].replace("Z", "+00:00"))
212
+ except ValueError as exc:
213
+ raise ExternalEvidenceValidationError("terminal_at must be an RFC3339 timestamp") from exc
214
+ if (
215
+ not isinstance(payload["demonstration"], bool)
216
+ or payload["eligible_for_learning"] is not False
217
+ ):
218
+ raise ExternalEvidenceValidationError("v1 evidence is observation-only")
219
+ receipt = payload["receipt"]
220
+ if not isinstance(receipt, dict) or set(receipt) != {
221
+ "sequence",
222
+ "head_digest",
223
+ "trust",
224
+ }:
225
+ raise ExternalEvidenceValidationError("receipt shape is invalid")
226
+ if (
227
+ not isinstance(receipt["sequence"], int)
228
+ or receipt["sequence"] < 1
229
+ or receipt["sequence"] > _MAX_RECEIPT_SEQUENCE
230
+ or receipt["trust"] != "local_hash_chain_only"
231
+ ):
232
+ raise ExternalEvidenceValidationError("receipt metadata is invalid")
233
+ if not isinstance(receipt["head_digest"], str) or not _SHA256.match(receipt["head_digest"]):
234
+ raise ExternalEvidenceValidationError("receipt head digest is invalid")
235
+ if not isinstance(payload["nodes"], list):
236
+ raise ExternalEvidenceValidationError("nodes must be a list")
237
+ if len(payload["nodes"]) > _MAX_NODES:
238
+ raise ExternalEvidenceValidationError("node count exceeds v1 safety limit")
239
+ artifact_count = 0
240
+ for node in payload["nodes"]:
241
+ if not isinstance(node, dict) or set(node) != {
242
+ "node_id",
243
+ "state",
244
+ "gate_passed",
245
+ "attempts",
246
+ "artifact_digests",
247
+ }:
248
+ raise ExternalEvidenceValidationError("node shape is invalid")
249
+ valid_node = _IDENTIFIER.match(str(node["node_id"])) and _IDENTIFIER.match(
250
+ str(node["state"])
251
+ )
252
+ if not valid_node:
253
+ raise ExternalEvidenceValidationError("node identifiers are invalid")
254
+ if (
255
+ node["gate_passed"] not in (True, False, None)
256
+ or not isinstance(node["attempts"], int)
257
+ or node["attempts"] < 1
258
+ ):
259
+ raise ExternalEvidenceValidationError("node gate metadata is invalid")
260
+ if not isinstance(node["artifact_digests"], list) or any(
261
+ not isinstance(item, str) or not _SHA256.match(item)
262
+ for item in node["artifact_digests"]
263
+ ):
264
+ raise ExternalEvidenceValidationError("node artifact digests are invalid")
265
+ if len(node["artifact_digests"]) > _MAX_ARTIFACTS_PER_NODE:
266
+ raise ExternalEvidenceValidationError("node artifact count exceeds v1 safety limit")
267
+ artifact_count += len(node["artifact_digests"])
268
+ if artifact_count > _MAX_ARTIFACTS_TOTAL:
269
+ raise ExternalEvidenceValidationError("artifact count exceeds v1 safety limit")
270
+ if len(_json(payload["nodes"]).encode("utf-8")) > _MAX_NODES_JSON_BYTES:
271
+ raise ExternalEvidenceValidationError("node evidence exceeds v1 size limit")
272
+
273
+
274
+ def _row(payload: dict[str, Any], digest: str) -> tuple[Any, ...]:
275
+ artifacts = sorted({item for node in payload["nodes"] for item in node["artifact_digests"]})
276
+ receipt = payload["receipt"]
277
+ return (
278
+ payload["profile_id"],
279
+ payload["contract"],
280
+ payload["workspace_id"],
281
+ payload["run_ref"],
282
+ payload["run_id"],
283
+ payload["outcome"],
284
+ payload["run_state"],
285
+ int(payload["demonstration"]),
286
+ 0,
287
+ payload["terminal_at"],
288
+ payload["graph_digest"],
289
+ payload["plan_digest"],
290
+ payload["policy_digest"],
291
+ receipt["sequence"],
292
+ receipt["head_digest"],
293
+ receipt["trust"],
294
+ _json(payload["nodes"]),
295
+ _json(artifacts),
296
+ digest,
297
+ datetime.now(timezone.utc).isoformat(),
298
+ )
299
+
300
+
301
+ def _assert_profile_open(conn: sqlite3.Connection, profile_id: str) -> None:
302
+ """Use M040's durable tombstone inside this writer transaction."""
303
+ table = conn.execute(
304
+ "SELECT 1 FROM sqlite_master WHERE type='table' "
305
+ "AND name='agent_receipt_profile_closures'"
306
+ ).fetchone()
307
+ if (
308
+ table is not None
309
+ and conn.execute(
310
+ "SELECT 1 FROM agent_receipt_profile_closures WHERE profile_id=?", (profile_id,)
311
+ ).fetchone()
312
+ is not None
313
+ ):
314
+ raise ProfileAdmissionError("profile is inactive or closing for erasure")
315
+
316
+
317
+ def _get_conn(
318
+ conn: sqlite3.Connection,
319
+ profile_id: str,
320
+ contract_id: str,
321
+ workspace_id: str,
322
+ run_ref: str,
323
+ ) -> dict[str, Any] | None:
324
+ row = conn.execute(
325
+ "SELECT * FROM external_evidence_receipts WHERE profile_id=? AND contract_id=? "
326
+ "AND workspace_id=? AND run_ref=?",
327
+ (profile_id, contract_id, workspace_id, run_ref),
328
+ ).fetchone()
329
+ if row is None:
330
+ return None
331
+ return {
332
+ "contract": row["contract_id"],
333
+ "profile_id": row["profile_id"],
334
+ "workspace_id": row["workspace_id"],
335
+ "run_ref": row["run_ref"],
336
+ "run_id": row["run_id"],
337
+ "outcome": row["outcome"],
338
+ "run_state": row["run_state"],
339
+ "demonstration": bool(row["demonstration"]),
340
+ "eligible_for_learning": bool(row["eligible_for_learning"]),
341
+ "terminal_at": row["terminal_at"],
342
+ "graph_digest": row["graph_digest"],
343
+ "plan_digest": row["plan_digest"],
344
+ "policy_digest": row["policy_digest"],
345
+ "receipt": {
346
+ "sequence": row["receipt_sequence"],
347
+ "head_digest": row["receipt_head_digest"],
348
+ "trust": row["receipt_trust"],
349
+ },
350
+ "nodes": json.loads(row["nodes_json"]),
351
+ }
352
+
353
+
354
+ def _payload_digest(payload: dict[str, Any]) -> str:
355
+ return hashlib.sha256(_json(payload).encode("utf-8")).hexdigest()
356
+
357
+
358
+ def _json(value: Any) -> str:
359
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
@@ -154,6 +154,12 @@ from superlocalmemory.storage.migrations import (
154
154
  from superlocalmemory.storage.migrations import (
155
155
  M040_agent_experience_receipts as _M040,
156
156
  )
157
+ from superlocalmemory.storage.migrations import (
158
+ M041_external_evidence_receipts as _M041,
159
+ )
160
+ from superlocalmemory.storage.migrations import (
161
+ M042_correction_case_ledger as _M042,
162
+ )
157
163
  from superlocalmemory.storage._schema_version import (
158
164
  SUPPORTED_SCHEMA_VERSION,
159
165
  SchemaVersionError,
@@ -234,6 +240,12 @@ MIGRATIONS: list[Migration] = [
234
240
  # lifecycle performs explicit cross-store erasure rather than an FK.
235
241
  Migration(name=_M040.NAME, db_target="learning", ddl=_M040.DDL,
236
242
  dependencies=(_M003.NAME,)),
243
+ Migration(name=_M041.NAME, db_target="learning", ddl=_M041.DDL,
244
+ dependencies=(_M040.NAME,)),
245
+ # Review-gated correction metadata is self-contained in memory.db. It
246
+ # contains identifiers only and does not alter temporal fact state.
247
+ Migration(name=_M042.NAME, db_target="memory", ddl=_M042.DDL,
248
+ dependencies=(_M032.NAME,)),
237
249
  # M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
238
250
  ]
239
251