superlocalmemory 4.1.11 → 4.1.13

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 (52) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +15 -4
  4. package/package.json +2 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +1 -1
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/agents/slm-memory-advisor.md +1 -1
  25. package/plugin-src/agents/slm-optimize-advisor.md +1 -1
  26. package/plugin-src/rules/AGENTS.md +1 -1
  27. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  38. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  39. package/pyproject.toml +1 -1
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +229 -0
  42. package/src/superlocalmemory/learning/database.py +2 -1
  43. package/src/superlocalmemory/mcp/tools_active.py +20 -5
  44. package/src/superlocalmemory/mcp/tools_brain.py +38 -1
  45. package/src/superlocalmemory/mcp/tools_learning.py +123 -4
  46. package/src/superlocalmemory/storage/_migration_internals.py +2 -0
  47. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  48. package/src/superlocalmemory/storage/agent_experience.py +38 -4
  49. package/src/superlocalmemory/storage/execution_learning.py +285 -0
  50. package/src/superlocalmemory/storage/migration_runner.py +3 -0
  51. package/src/superlocalmemory/storage/migrations/M050_execution_learning_v2.py +70 -0
  52. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
@@ -254,9 +254,33 @@ class AgentExperienceStore:
254
254
  external_count = conn.execute(
255
255
  "DELETE FROM external_evidence_receipts WHERE profile_id=?", (profile_id,)
256
256
  ).rowcount
257
+ execution_count = 0
258
+ execution_tables = {
259
+ row[0]
260
+ for row in conn.execute(
261
+ "SELECT name FROM sqlite_master WHERE type='table' "
262
+ "AND name IN ('execution_learning_receipts', 'execution_learning_events')"
263
+ )
264
+ }
265
+ if execution_tables and execution_tables != {
266
+ "execution_learning_receipts", "execution_learning_events"
267
+ }:
268
+ raise sqlite3.OperationalError("incomplete execution-learning receipt schema")
269
+ has_execution = bool(execution_tables)
270
+ if has_execution:
271
+ execution_count += conn.execute(
272
+ "DELETE FROM execution_learning_events WHERE profile_id=?", (profile_id,)
273
+ ).rowcount
274
+ execution_count += conn.execute(
275
+ "DELETE FROM execution_learning_receipts WHERE profile_id=?", (profile_id,)
276
+ ).rowcount
257
277
  receipt_tables = ["agent_experiences", "cognitive_turn_receipts"]
258
278
  if has_external:
259
279
  receipt_tables.append("external_evidence_receipts")
280
+ if has_execution:
281
+ receipt_tables.extend([
282
+ "execution_learning_receipts", "execution_learning_events",
283
+ ])
260
284
  residue = sum(
261
285
  int(
262
286
  conn.execute(
@@ -267,7 +291,7 @@ class AgentExperienceStore:
267
291
  )
268
292
  if residue:
269
293
  raise RuntimeError("learning receipt erasure left profile residue")
270
- return experience_count + turn_count + external_count
294
+ return experience_count + turn_count + external_count + execution_count
271
295
 
272
296
  return self._write(erase)
273
297
 
@@ -434,7 +458,8 @@ def purge_profile_receipts(
434
458
  for row in conn.execute(
435
459
  "SELECT name FROM sqlite_master WHERE type='table' "
436
460
  "AND name IN ('agent_experiences', 'cognitive_turn_receipts', "
437
- "'agent_receipt_profile_closures', 'external_evidence_receipts')"
461
+ "'agent_receipt_profile_closures', 'external_evidence_receipts', "
462
+ "'execution_learning_receipts', 'execution_learning_events')"
438
463
  )
439
464
  }
440
465
  if not tables:
@@ -443,13 +468,22 @@ def purge_profile_receipts(
443
468
  "agent_experiences", "cognitive_turn_receipts", "agent_receipt_profile_closures"
444
469
  }
445
470
  if tables != expected:
446
- if tables == expected | {"external_evidence_receipts"}:
471
+ optional = {"external_evidence_receipts"}
472
+ execution = {"execution_learning_receipts", "execution_learning_events"}
473
+ if tables in (expected | optional, expected | execution, expected | optional | execution):
447
474
  from superlocalmemory.storage.migrations import M041_external_evidence_receipts as m041
448
475
 
449
476
  with sqlite3.connect(path) as conn:
450
477
  # Erasure needs a valid table, not its optional performance indexes.
451
478
  # A damaged index must never strand profile-scoped evidence.
452
- if m041._table_is_valid(conn):
479
+ external_ok = (
480
+ "external_evidence_receipts" not in tables or m041._table_is_valid(conn)
481
+ )
482
+ execution_ok = (
483
+ not execution.intersection(tables)
484
+ or execution <= tables
485
+ )
486
+ if external_ok and execution_ok:
453
487
  return AgentExperienceStore(
454
488
  path, is_profile_active=lambda _: True
455
489
  ).erase_profile(profile_id, close_profile=close_profile)
@@ -0,0 +1,285 @@
1
+ """Bridge-v2 immutable receipt storage and strictly bounded execution learning.
2
+
3
+ This module deliberately has no dependency on recall, semantic facts, or user
4
+ preferences. Bounded Loops receipts can only update the rebuildable execution
5
+ event projection after the consumer validates every eligibility condition.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import sqlite3
14
+ from copy import deepcopy
15
+ from dataclasses import dataclass
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Callable
19
+
20
+ from superlocalmemory.storage.agent_experience import (
21
+ _ProfileAdmissionGate,
22
+ _PROCESS_LOCKS_GUARD,
23
+ _PROFILE_GATES,
24
+ ProfileAdmissionError,
25
+ )
26
+
27
+ _CONTRACT = "bounded-loops.dev/slm-bridge/v2"
28
+ _SHA256_PREFIX = "sha256:"
29
+ _ALLOWED_STATES = frozenset({"SUCCEEDED", "FAILED"})
30
+ _MAX_PAYLOAD_BYTES = 32 * 1024
31
+ _MAX_NODES = 256
32
+ _MAX_ARTIFACTS_PER_NODE = 64
33
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
34
+ _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
35
+
36
+
37
+ class ExecutionLearningValidationError(ValueError):
38
+ """A v2 receipt is not eligible for execution-learning ingestion."""
39
+
40
+
41
+ # This is deliberately an in-process capability, not a serializable field in
42
+ # the producer payload. An MCP producer can send JSON; it cannot mint this
43
+ # boundary witness. It prevents an accidental future caller from turning a
44
+ # schema-valid JSON document directly into learned execution behaviour.
45
+ _BRIDGE_SEAL = object()
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class VerifiedExecutionEvidence:
50
+ """A v2 payload bound to one negotiated, locally observed producer session.
51
+
52
+ ``payload`` intentionally remains available for the immutable receipt
53
+ record. The private seal is checked by :class:`ExecutionLearningStore`
54
+ and never crosses MCP or disk boundaries.
55
+ """
56
+
57
+ payload: dict[str, Any]
58
+ producer_identity: str
59
+ capability_digest: str
60
+ terminal_listing_digest: str
61
+ payload_sha256: str
62
+ _seal: object
63
+
64
+
65
+ def _seal_verified_execution_evidence(
66
+ payload: dict[str, Any],
67
+ *,
68
+ producer_identity: str,
69
+ capability_digest: str,
70
+ terminal_listing_digest: str,
71
+ ) -> VerifiedExecutionEvidence:
72
+ """Create the sole accepted execution-learning input at the MCP boundary."""
73
+ _validate(payload)
74
+ for label, value in {
75
+ "producer identity": producer_identity,
76
+ "capability digest": capability_digest,
77
+ "terminal listing digest": terminal_listing_digest,
78
+ }.items():
79
+ if not isinstance(value, str) or not value:
80
+ raise ExecutionLearningValidationError(f"v2 receipt has no verified {label}")
81
+ sealed_payload = deepcopy(payload)
82
+ return VerifiedExecutionEvidence(
83
+ payload=sealed_payload,
84
+ producer_identity=producer_identity,
85
+ capability_digest=capability_digest,
86
+ terminal_listing_digest=terminal_listing_digest,
87
+ payload_sha256=_digest(sealed_payload),
88
+ _seal=_BRIDGE_SEAL,
89
+ )
90
+
91
+
92
+ class ExecutionLearningStore:
93
+ """Atomically persist v2 receipts before their derived event projection."""
94
+
95
+ def __init__(self, path: str | Path, *, is_profile_active: Callable[[str], bool]) -> None:
96
+ self._path = Path(path).resolve()
97
+ self._is_profile_active = is_profile_active
98
+ with _PROCESS_LOCKS_GUARD:
99
+ self._gate = _PROFILE_GATES.setdefault(str(self._path), _ProfileAdmissionGate())
100
+
101
+ def ingest(self, evidence: VerifiedExecutionEvidence) -> bool:
102
+ if (
103
+ not isinstance(evidence, VerifiedExecutionEvidence)
104
+ or evidence._seal is not _BRIDGE_SEAL
105
+ ):
106
+ raise ExecutionLearningValidationError(
107
+ "v2 execution learning requires verified bridge provenance"
108
+ )
109
+ payload = evidence.payload
110
+ if evidence.payload_sha256 != _digest(payload):
111
+ raise ExecutionLearningValidationError(
112
+ "v2 execution-learning provenance was modified after verification"
113
+ )
114
+ _validate(payload)
115
+ profile_id = payload["profile_id"]
116
+ self._gate.admit(profile_id, self._is_profile_active)
117
+ digest = _digest(payload)
118
+ receipt = payload["receipt"]
119
+ route_key = _route_key(payload["route"])
120
+ signal = 1 if payload["run_state"] == "SUCCEEDED" else -1
121
+ now = datetime.now(timezone.utc).isoformat()
122
+ conn: sqlite3.Connection | None = None
123
+ try:
124
+ conn = sqlite3.connect(str(self._path), timeout=1.0, isolation_level=None)
125
+ conn.execute("BEGIN IMMEDIATE")
126
+ # The same process-wide admission gate drains in-flight writes
127
+ # before erasure. This durable closure check also prevents a late
128
+ # transaction from resurrecting a profile after a completed close.
129
+ closed = conn.execute(
130
+ "SELECT 1 FROM agent_receipt_profile_closures WHERE profile_id=?", (profile_id,)
131
+ ).fetchone()
132
+ if closed is not None:
133
+ raise ProfileAdmissionError("profile is inactive or closing for erasure")
134
+ existing = conn.execute(
135
+ "SELECT payload_sha256 FROM execution_learning_receipts "
136
+ "WHERE profile_id=? AND workspace_id=? AND run_ref=?",
137
+ (profile_id, payload["workspace_id"], payload["run_ref"]),
138
+ ).fetchone()
139
+ if existing is not None:
140
+ if existing[0] != digest:
141
+ raise ExecutionLearningValidationError(
142
+ "external run address has a different receipt head"
143
+ )
144
+ conn.execute("ROLLBACK")
145
+ return False
146
+ conn.execute(
147
+ "INSERT INTO execution_learning_receipts "
148
+ "(profile_id, workspace_id, run_ref, run_id, receipt_head_digest, "
149
+ "payload_json, payload_sha256, producer_identity, capability_digest, "
150
+ "terminal_listing_digest, observed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
151
+ (profile_id, payload["workspace_id"], payload["run_ref"], payload["run_id"],
152
+ receipt["head_digest"], _json(payload), digest, evidence.producer_identity,
153
+ evidence.capability_digest, evidence.terminal_listing_digest, now),
154
+ )
155
+ conn.execute(
156
+ "INSERT INTO execution_learning_events "
157
+ "(profile_id, workspace_id, run_ref, receipt_head_digest, route_key, signal, created_at) "
158
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
159
+ (profile_id, payload["workspace_id"], payload["run_ref"],
160
+ receipt["head_digest"], route_key, signal, now),
161
+ )
162
+ conn.execute("COMMIT")
163
+ return True
164
+ except Exception:
165
+ if conn is not None and conn.in_transaction:
166
+ conn.execute("ROLLBACK")
167
+ raise
168
+ finally:
169
+ if conn is not None:
170
+ conn.close()
171
+ self._gate.release(profile_id)
172
+
173
+ def status(self, profile_id: str) -> dict[str, int]:
174
+ conn = sqlite3.connect(str(self._path), timeout=0.5)
175
+ try:
176
+ receipts = conn.execute(
177
+ "SELECT COUNT(*) FROM execution_learning_receipts WHERE profile_id=?", (profile_id,)
178
+ ).fetchone()[0]
179
+ totals = conn.execute(
180
+ "SELECT COUNT(*), COALESCE(SUM(signal = 1), 0), COALESCE(SUM(signal = -1), 0) "
181
+ "FROM execution_learning_events WHERE profile_id=?", (profile_id,),
182
+ ).fetchone()
183
+ return {"receipts_total": int(receipts), "learning_events_total": int(totals[0]),
184
+ "positive_events": int(totals[1]), "negative_events": int(totals[2])}
185
+ finally:
186
+ conn.close()
187
+
188
+
189
+ def _validate(payload: dict[str, Any]) -> None:
190
+ required = {"contract", "profile_id", "workspace_id", "run_ref", "run_id", "outcome",
191
+ "run_state", "demonstration", "eligible_for_learning", "terminal_at",
192
+ "graph_digest", "plan_digest", "policy_digest", "receipt", "nodes",
193
+ "learning_authority", "route", "usage"}
194
+ if set(payload) != required or payload.get("contract") != _CONTRACT:
195
+ raise ExecutionLearningValidationError("v2 receipt does not match the bounded contract")
196
+ if len(_json(payload).encode("utf-8")) > _MAX_PAYLOAD_BYTES:
197
+ raise ExecutionLearningValidationError("v2 receipt exceeds the bounded payload size")
198
+ for field in ("profile_id", "workspace_id", "run_ref", "run_id"):
199
+ if not isinstance(payload[field], str) or not _SAFE_ID.fullmatch(payload[field]):
200
+ raise ExecutionLearningValidationError(f"v2 receipt has an invalid {field}")
201
+ for field in ("workspace_id", "graph_digest", "plan_digest", "policy_digest"):
202
+ if not isinstance(payload[field], str) or not _DIGEST.fullmatch(payload[field]):
203
+ raise ExecutionLearningValidationError(f"v2 receipt has an invalid {field}")
204
+ if not isinstance(payload["terminal_at"], str) or len(payload["terminal_at"]) > 64:
205
+ raise ExecutionLearningValidationError("v2 receipt has an invalid terminal timestamp")
206
+ if payload["demonstration"] is not False or payload["eligible_for_learning"] is not True:
207
+ raise ExecutionLearningValidationError("receipt is not eligible for execution learning")
208
+ if payload["run_state"] not in _ALLOWED_STATES or payload["outcome"] != payload["run_state"]:
209
+ raise ExecutionLearningValidationError("only terminal succeeded or executed-gate failure is learnable")
210
+ receipt, authority = payload["receipt"], payload["learning_authority"]
211
+ if not isinstance(receipt, dict) or not isinstance(authority, dict):
212
+ raise ExecutionLearningValidationError("receipt authority is malformed")
213
+ if set(receipt) != {"sequence", "head_digest", "trust"}:
214
+ raise ExecutionLearningValidationError("receipt chain metadata is malformed")
215
+ if (
216
+ isinstance(receipt["sequence"], bool)
217
+ or not isinstance(receipt["sequence"], int)
218
+ or receipt["sequence"] < 1
219
+ or receipt["trust"] != "local_hash_chain_only"
220
+ or not isinstance(receipt.get("head_digest"), str)
221
+ or not _DIGEST.fullmatch(receipt["head_digest"])
222
+ ):
223
+ raise ExecutionLearningValidationError("receipt chain head is invalid")
224
+ if authority != {"scope": "execution_reliability_only", "reason_code": "verified_terminal_receipt",
225
+ "verification_state": "reconciled", "gate_authority": "deterministic_gate",
226
+ "trust_class": "local_hash_chain_only"}:
227
+ raise ExecutionLearningValidationError("receipt lacks bounded deterministic learning authority")
228
+ _validate_nodes(payload["nodes"])
229
+ _validate_route(payload["route"])
230
+ _validate_usage(payload["usage"])
231
+
232
+
233
+ def _validate_nodes(nodes: Any) -> None:
234
+ if not isinstance(nodes, list) or not nodes or len(nodes) > _MAX_NODES:
235
+ raise ExecutionLearningValidationError("receipt nodes are malformed")
236
+ for node in nodes:
237
+ if not isinstance(node, dict) or set(node) != {
238
+ "node_id", "state", "gate_passed", "attempts", "artifact_digests"
239
+ }:
240
+ raise ExecutionLearningValidationError("receipt node schema is malformed")
241
+ if (
242
+ not isinstance(node["node_id"], str)
243
+ or not _SAFE_ID.fullmatch(node["node_id"])
244
+ or not isinstance(node["state"], str)
245
+ or not _SAFE_ID.fullmatch(node["state"])
246
+ or node["gate_passed"] not in (True, False, None)
247
+ or isinstance(node["attempts"], bool)
248
+ or not isinstance(node["attempts"], int)
249
+ or not 0 <= node["attempts"] <= 10_000
250
+ or not isinstance(node["artifact_digests"], list)
251
+ or len(node["artifact_digests"]) > _MAX_ARTIFACTS_PER_NODE
252
+ or not all(isinstance(item, str) and _DIGEST.fullmatch(item)
253
+ for item in node["artifact_digests"])
254
+ ):
255
+ raise ExecutionLearningValidationError("receipt node values are malformed")
256
+
257
+
258
+ def _validate_route(route: Any) -> None:
259
+ if not isinstance(route, dict) or set(route) - {"runner", "provider", "model", "effort"}:
260
+ raise ExecutionLearningValidationError("route or usage is malformed")
261
+ if not all(isinstance(value, str) and _SAFE_ID.fullmatch(value) for value in route.values()):
262
+ raise ExecutionLearningValidationError("route or usage is malformed")
263
+
264
+
265
+ def _validate_usage(usage: Any) -> None:
266
+ if (
267
+ not isinstance(usage, dict)
268
+ or set(usage) != {"attempts"}
269
+ or isinstance(usage["attempts"], bool)
270
+ or not isinstance(usage["attempts"], int)
271
+ or not 0 <= usage["attempts"] <= 1_000_000
272
+ ):
273
+ raise ExecutionLearningValidationError("route or usage is malformed")
274
+
275
+
276
+ def _route_key(route: dict[str, Any]) -> str:
277
+ return "|".join(str(route.get(key, "")) for key in ("runner", "provider", "model", "effort"))
278
+
279
+
280
+ def _json(value: Any) -> str:
281
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
282
+
283
+
284
+ def _digest(value: dict[str, Any]) -> str:
285
+ return hashlib.sha256(_json(value).encode("utf-8")).hexdigest()
@@ -168,6 +168,7 @@ from superlocalmemory.storage.migrations import (
168
168
  M047_fisher_vectors_are_stored_like_every_other_vector as _M047,
169
169
  M048_upcoming_holds_only_what_is_upcoming as _M048,
170
170
  M049_a_schema_version_marker_is_one_row as _M049,
171
+ M050_execution_learning_v2 as _M050,
171
172
  )
172
173
  from superlocalmemory.storage.migrations import (
173
174
  M043_quarantine_display_summaries as _M043,
@@ -259,6 +260,8 @@ MIGRATIONS: list[Migration] = [
259
260
  dependencies=(_M003.NAME,)),
260
261
  Migration(name=_M041.NAME, db_target="learning", ddl=_M041.DDL,
261
262
  dependencies=(_M040.NAME,)),
263
+ Migration(name=_M050.NAME, db_target="learning", ddl=_M050.DDL,
264
+ dependencies=(_M041.NAME,)),
262
265
  # Review-gated correction metadata is self-contained in memory.db. It
263
266
  # contains identifiers only and does not alter temporal fact state.
264
267
  Migration(name=_M042.NAME, db_target="memory", ddl=_M042.DDL,
@@ -0,0 +1,70 @@
1
+ """M050 — immutable bridge-v2 receipts and rebuildable execution learning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ NAME = "M050_execution_learning_v2"
8
+ DB_TARGET = "learning"
9
+
10
+ DDL = """
11
+ BEGIN IMMEDIATE;
12
+ CREATE TABLE IF NOT EXISTS execution_learning_receipts (
13
+ profile_id TEXT NOT NULL,
14
+ workspace_id TEXT NOT NULL,
15
+ run_ref TEXT NOT NULL,
16
+ run_id TEXT NOT NULL,
17
+ receipt_head_digest TEXT NOT NULL,
18
+ payload_json TEXT NOT NULL,
19
+ payload_sha256 TEXT NOT NULL,
20
+ producer_identity TEXT NOT NULL,
21
+ capability_digest TEXT NOT NULL,
22
+ terminal_listing_digest TEXT NOT NULL,
23
+ observed_at TEXT NOT NULL,
24
+ PRIMARY KEY (profile_id, workspace_id, run_ref)
25
+ );
26
+ CREATE TABLE IF NOT EXISTS execution_learning_events (
27
+ profile_id TEXT NOT NULL,
28
+ workspace_id TEXT NOT NULL,
29
+ run_ref TEXT NOT NULL,
30
+ receipt_head_digest TEXT NOT NULL,
31
+ route_key TEXT NOT NULL,
32
+ signal INTEGER NOT NULL CHECK (signal IN (-1, 1)),
33
+ created_at TEXT NOT NULL,
34
+ PRIMARY KEY (profile_id, workspace_id, run_ref, receipt_head_digest)
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_execution_learning_events_profile_route
37
+ ON execution_learning_events (profile_id, route_key);
38
+ COMMIT;
39
+ """
40
+
41
+
42
+ def apply(conn: sqlite3.Connection) -> None:
43
+ """Install only additive, independently-owned v2 tables."""
44
+ conn.executescript(DDL)
45
+ if not verify(conn):
46
+ raise sqlite3.OperationalError("M050 execution-learning schema did not reach its end-state")
47
+
48
+
49
+ def verify(conn: sqlite3.Connection) -> bool:
50
+ required = {"execution_learning_receipts", "execution_learning_events"}
51
+ tables = {row[0] for row in conn.execute(
52
+ "SELECT name FROM sqlite_master WHERE type='table'"
53
+ )}
54
+ if not required <= tables:
55
+ return False
56
+ receipt_columns = {
57
+ row[1] for row in conn.execute("PRAGMA table_info(execution_learning_receipts)")
58
+ }
59
+ return {
60
+ "producer_identity", "capability_digest", "terminal_listing_digest",
61
+ } <= receipt_columns
62
+
63
+
64
+ def repair(conn: sqlite3.Connection) -> None:
65
+ """Restore missing derived indexes without mutating immutable receipts."""
66
+ if not verify(conn):
67
+ apply(conn)
68
+ return
69
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_execution_learning_events_profile_route "
70
+ "ON execution_learning_events (profile_id, route_key)")
@@ -33,6 +33,7 @@ from . import (
33
33
  M040_agent_experience_receipts,
34
34
  M041_external_evidence_receipts,
35
35
  M042_correction_case_ledger,
36
+ M050_execution_learning_v2,
36
37
  )
37
38
 
38
39
  # ---------------------------------------------------------------------------
@@ -91,6 +92,7 @@ __all__ = (
91
92
  "M040_agent_experience_receipts",
92
93
  "M041_external_evidence_receipts",
93
94
  "M042_correction_case_ledger",
95
+ "M050_execution_learning_v2",
94
96
  # Legacy re-exports (backward compat):
95
97
  "CURRENT_SCHEMA_VERSION",
96
98
  "get_schema_version",