superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -122,6 +122,12 @@ from superlocalmemory.storage.migrations import (
122
122
  from superlocalmemory.storage.migrations import (
123
123
  M030_entity_explorer_indexes as _M030,
124
124
  )
125
+ from superlocalmemory.storage.migrations import (
126
+ M031_dead_letter_operations as _M031,
127
+ )
128
+ from superlocalmemory.storage.migrations import (
129
+ M032_write_coordinator_admission as _M032,
130
+ )
125
131
 
126
132
  # Map migration name → module (used for the optional ``verify(conn)`` hook
127
133
  # that lets the runner detect "already applied" state when an idempotent
@@ -156,6 +162,8 @@ _MODULES = {
156
162
  _M028.NAME: _M028,
157
163
  _M029.NAME: _M029,
158
164
  _M030.NAME: _M030,
165
+ _M031.NAME: _M031,
166
+ _M032.NAME: _M032,
159
167
  }
160
168
 
161
169
  logger = logging.getLogger(__name__)
@@ -169,6 +177,12 @@ _KNOWN_EQUIVALENT_DDL_HASHES: dict[str, frozenset[str]] = {
169
177
  # v3.4.22 model_version-default variant shipped through 3.6.x.
170
178
  "d28666fa1dfa66e6514efd288e6748363513da2255a4cee95d80f233e6728ae7",
171
179
  }),
180
+ _M032.NAME: frozenset({
181
+ # Provisional 3.8.6 development ledger: global idempotency_key and
182
+ # operation_id uniqueness. Its standalone table is safely rebuilt by
183
+ # M032.repair() into the profile-scoped receipt contract.
184
+ "e45df41becba3d0c3342eca5ec3bd83aa899eef76943c819d2da73b4ca1625a7",
185
+ }),
172
186
  }
173
187
 
174
188
 
@@ -216,6 +230,12 @@ MIGRATIONS: list[Migration] = [
216
230
  Migration(name=_M024.NAME, db_target="memory", ddl=_M024.DDL),
217
231
  Migration(name=_M019.NAME, db_target="memory", ddl=_M019.DDL,
218
232
  dependencies=(_M018.NAME,)),
233
+ # M031 creates dead_letter_operations — standalone table, no FK to engine-
234
+ # bootstrapped tables, so it can run during apply_all (before engine init).
235
+ Migration(name=_M031.NAME, db_target="memory", ddl=_M031.DDL),
236
+ # M032 is standalone and must precede daemon readiness: typed writes use
237
+ # this append-only receipt ledger for durable idempotency.
238
+ Migration(name=_M032.NAME, db_target="memory", ddl=_M032.DDL),
219
239
  # M006 + M011 are deliberately NOT here — see DEFERRED_MIGRATIONS below.
220
240
  ]
221
241
 
@@ -415,6 +435,30 @@ def _apply_single(
415
435
  )
416
436
  except sqlite3.Error: # pragma: no cover
417
437
  pass
438
+ if dry_run:
439
+ return (
440
+ "skipped",
441
+ "dry-run: would repair allowlisted historical schema",
442
+ )
443
+ repair_fn = getattr(mod, "repair", None) if mod is not None else None
444
+ if callable(repair_fn):
445
+ try:
446
+ repair_fn(conn)
447
+ if not bool(verify_fn(conn)):
448
+ return (
449
+ "failed",
450
+ f"safe repair did not restore {migration.name}",
451
+ )
452
+ _upsert_log(conn, migration.name, ddl_hash, "complete")
453
+ return (
454
+ "applied",
455
+ "allowlisted historical schema repaired safely",
456
+ )
457
+ except sqlite3.Error as exc:
458
+ return (
459
+ "failed",
460
+ f"safe repair failed for {migration.name}: {exc}",
461
+ )
418
462
  detail = (
419
463
  f"DDL drift detected for {migration.name}: "
420
464
  f"logged={logged_hash[:8]}... current={ddl_hash[:8]}..."
@@ -101,17 +101,30 @@ def _now() -> str:
101
101
  return datetime.now(UTC).isoformat()
102
102
 
103
103
 
104
- def _connect(db_path: Path) -> sqlite3.Connection:
105
- conn = sqlite3.connect(str(db_path), timeout=5)
104
+ def _connect_read(db_path: Path) -> sqlite3.Connection:
105
+ """Read-only connection with system-default busy_timeout.
106
+
107
+ Concurrency fix (v3.8.4): busy_timeout raised from 5 s to match the
108
+ system default (10 s, env SLM_DB_BUSY_TIMEOUT_MS). Timeout raised from
109
+ 5 s to 10 s for the same reason. Write connections now go through
110
+ memory_write() so they acquire get_write_lock() and never use this helper.
111
+ """
112
+ import os
113
+
114
+ try:
115
+ ms = max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
116
+ except (TypeError, ValueError):
117
+ ms = 10000
118
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
106
119
  conn.row_factory = sqlite3.Row
107
- conn.execute("PRAGMA busy_timeout=5000")
120
+ conn.execute(f"PRAGMA busy_timeout={ms}")
108
121
  conn.execute("PRAGMA foreign_keys=ON")
109
122
  return conn
110
123
 
111
124
 
112
125
  def get_repair_status(db_path: Path) -> dict[str, int | str]:
113
126
  """Read durable backfill progress without inferring it from schema."""
114
- conn = _connect(Path(db_path))
127
+ conn = _connect_read(Path(db_path))
115
128
  try:
116
129
  row = conn.execute(
117
130
  "SELECT state,target_fact_rowid,last_fact_rowid,scanned,inserted,"
@@ -142,58 +155,70 @@ def _entity_ids(raw: object) -> tuple[str, ...]:
142
155
 
143
156
 
144
157
  def _repair_batch(conn: sqlite3.Connection, batch_size: int) -> dict[str, int | bool]:
145
- conn.execute("BEGIN IMMEDIATE")
146
- try:
147
- status = conn.execute(
148
- "SELECT last_fact_rowid,target_fact_rowid "
149
- "FROM fact_entity_association_repair_state "
150
- "WHERE repair_key='historical-backfill'"
151
- ).fetchone()
152
- cursor = int(status["last_fact_rowid"] or 0)
153
- target = int(status["target_fact_rowid"])
154
- rows = conn.execute(
155
- "SELECT rowid,fact_id,profile_id,canonical_entities_json "
156
- "FROM atomic_facts WHERE rowid>? AND rowid<=? "
157
- "ORDER BY rowid LIMIT ?",
158
- (cursor, target, batch_size),
159
- ).fetchall()
160
- if not rows:
161
- conn.execute(
162
- "UPDATE fact_entity_association_repair_state "
163
- "SET state='complete',last_error='',updated_at=? "
164
- "WHERE repair_key='historical-backfill'",
165
- (_now(),),
166
- )
167
- conn.commit()
168
- return {"scanned": 0, "inserted": 0, "complete": True}
169
- inserted = 0
170
- for row in rows:
171
- for entity_id in _entity_ids(row["canonical_entities_json"]):
172
- result = conn.execute(
173
- "INSERT OR IGNORE INTO fact_entity_associations "
174
- "(profile_id,fact_id,entity_id,first_operation_id,"
175
- "count_applied) "
176
- "SELECT ?,?,?,?,? FROM canonical_entities "
177
- "WHERE profile_id=? AND entity_id=?",
178
- (
179
- row["profile_id"], row["fact_id"], entity_id,
180
- "migration-backfill", 0,
181
- row["profile_id"], entity_id,
182
- ),
183
- )
184
- inserted += max(0, result.rowcount)
158
+ """Execute one backfill batch on *conn*.
159
+
160
+ Concurrency fix (v3.8.4): caller MUST hold the write lock via
161
+ memory_write() before calling this function. The explicit
162
+ ``conn.execute("BEGIN IMMEDIATE")`` has been removed because:
163
+
164
+ * memory_write() already acquired get_write_lock() (Python-level
165
+ serialisation) no other in-process writer can start.
166
+ * SQLite's implicit deferred transaction is promoted to a write
167
+ transaction on the first INSERT, which is equivalent to BEGIN
168
+ IMMEDIATE for in-process serialisation.
169
+ * The explicit BEGIN IMMEDIATE previously bypassed get_write_lock()
170
+ and would retry at the SQLite layer only (busy_timeout=5 s, now
171
+ 10 s) without respecting the Python-level write lock order.
172
+
173
+ Transaction boundary (commit/rollback) is managed by memory_write().
174
+ """
175
+ status = conn.execute(
176
+ "SELECT last_fact_rowid,target_fact_rowid "
177
+ "FROM fact_entity_association_repair_state "
178
+ "WHERE repair_key='historical-backfill'"
179
+ ).fetchone()
180
+ if status is None:
181
+ return {"scanned": 0, "inserted": 0, "complete": True}
182
+ cursor = int(status["last_fact_rowid"] or 0)
183
+ target = int(status["target_fact_rowid"])
184
+ rows = conn.execute(
185
+ "SELECT rowid,fact_id,profile_id,canonical_entities_json "
186
+ "FROM atomic_facts WHERE rowid>? AND rowid<=? "
187
+ "ORDER BY rowid LIMIT ?",
188
+ (cursor, target, batch_size),
189
+ ).fetchall()
190
+ if not rows:
185
191
  conn.execute(
186
- "UPDATE fact_entity_association_repair_state SET "
187
- "state='running',last_fact_rowid=?,scanned=scanned+?,"
188
- "inserted=inserted+?,last_error='',updated_at=? "
192
+ "UPDATE fact_entity_association_repair_state "
193
+ "SET state='complete',last_error='',updated_at=? "
189
194
  "WHERE repair_key='historical-backfill'",
190
- (int(rows[-1]["rowid"]), len(rows), inserted, _now()),
195
+ (_now(),),
191
196
  )
192
- conn.commit()
193
- return {"scanned": len(rows), "inserted": inserted, "complete": False}
194
- except Exception:
195
- conn.rollback()
196
- raise
197
+ return {"scanned": 0, "inserted": 0, "complete": True}
198
+ inserted = 0
199
+ for row in rows:
200
+ for entity_id in _entity_ids(row["canonical_entities_json"]):
201
+ result = conn.execute(
202
+ "INSERT OR IGNORE INTO fact_entity_associations "
203
+ "(profile_id,fact_id,entity_id,first_operation_id,"
204
+ "count_applied) "
205
+ "SELECT ?,?,?,?,? FROM canonical_entities "
206
+ "WHERE profile_id=? AND entity_id=?",
207
+ (
208
+ row["profile_id"], row["fact_id"], entity_id,
209
+ "migration-backfill", 0,
210
+ row["profile_id"], entity_id,
211
+ ),
212
+ )
213
+ inserted += max(0, result.rowcount)
214
+ conn.execute(
215
+ "UPDATE fact_entity_association_repair_state SET "
216
+ "state='running',last_fact_rowid=?,scanned=scanned+?,"
217
+ "inserted=inserted+?,last_error='',updated_at=? "
218
+ "WHERE repair_key='historical-backfill'",
219
+ (int(rows[-1]["rowid"]), len(rows), inserted, _now()),
220
+ )
221
+ return {"scanned": len(rows), "inserted": inserted, "complete": False}
197
222
 
198
223
 
199
224
  def repair_fact_entity_associations(
@@ -202,34 +227,44 @@ def repair_fact_entity_associations(
202
227
  batch_size: int = 250,
203
228
  max_batches: int = 1,
204
229
  ) -> dict[str, int | bool]:
205
- """Run bounded, restartable short-transaction backfill batches."""
230
+ """Run bounded, restartable short-transaction backfill batches.
231
+
232
+ Concurrency fix (v3.8.4): each batch is now wrapped in memory_write()
233
+ which acquires get_write_lock() (process-level write serialisation) and
234
+ sets the system-default busy_timeout (10 s) before opening the SQLite
235
+ connection. Previously the loop used a single raw _connect() (timeout=5,
236
+ busy_timeout=5000) without get_write_lock(), which bypassed in-process
237
+ write serialisation and could cause SQLITE_BUSY after only 5 s.
238
+ """
239
+ from superlocalmemory.storage.memory_write import memory_write
240
+
206
241
  if batch_size < 1 or max_batches < 1:
207
242
  raise ValueError("batch_size and max_batches must be positive")
208
- totals = {"scanned": 0, "inserted": 0, "complete": False}
209
- conn = _connect(Path(db_path))
210
- try:
211
- for _ in range(max_batches):
212
- result = _repair_batch(conn, batch_size)
213
- totals["scanned"] += int(result["scanned"])
214
- totals["inserted"] += int(result["inserted"])
215
- totals["complete"] = bool(result["complete"])
216
- if totals["complete"]:
217
- break
218
- return totals
219
- except sqlite3.Error as exc:
243
+ totals: dict[str, int | bool] = {"scanned": 0, "inserted": 0, "complete": False}
244
+ for _ in range(max_batches):
220
245
  try:
221
- conn.execute(
222
- "UPDATE fact_entity_association_repair_state SET "
223
- "state='retrying',last_error=?,updated_at=? "
224
- "WHERE repair_key='historical-backfill'",
225
- (type(exc).__name__, _now()),
226
- )
227
- conn.commit()
228
- except sqlite3.Error:
229
- pass
230
- raise
231
- finally:
232
- conn.close()
246
+ with memory_write(Path(db_path)) as conn:
247
+ conn.execute("PRAGMA foreign_keys=ON")
248
+ result = _repair_batch(conn, batch_size)
249
+ totals["scanned"] += int(result["scanned"])
250
+ totals["inserted"] += int(result["inserted"])
251
+ totals["complete"] = bool(result["complete"])
252
+ except sqlite3.Error as exc:
253
+ # On error, record the retrying state in a separate short write.
254
+ try:
255
+ with memory_write(Path(db_path)) as econn:
256
+ econn.execute(
257
+ "UPDATE fact_entity_association_repair_state SET "
258
+ "state='retrying',last_error=?,updated_at=? "
259
+ "WHERE repair_key='historical-backfill'",
260
+ (type(exc).__name__, _now()),
261
+ )
262
+ except sqlite3.Error:
263
+ pass
264
+ raise
265
+ if totals["complete"]:
266
+ break
267
+ return totals
233
268
 
234
269
 
235
270
  def verify(conn: sqlite3.Connection) -> bool:
@@ -0,0 +1,80 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """M031 — dead-letter queue for exhausted ingestion operations (Fix E, issue #77).
6
+
7
+ Additive migration: creates dead_letter_operations if not present.
8
+ Existing rows in ingestion_operations are unaffected.
9
+
10
+ When an M018 ingestion operation exhausts _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
11
+ (10) it previously remained silently in FAILED state — invisible to operators and
12
+ unreachable by the materialiser. This table gives operators a persistent,
13
+ inspectable record of every poisoned operation: original content, error, attempt
14
+ count, timestamps, and profile scope.
15
+
16
+ Schema design:
17
+ - original_op_id references ingestion_operations.operation_id (soft ref — no FK
18
+ so that dead-lettered rows survive if the source row is later cleaned up).
19
+ - profile_id allows per-profile DLQ dashboards.
20
+ - dead_lettered_at defaults to the current epoch for point-in-time auditing.
21
+ - No TTL/expiry here — retention policy belongs to a future maintenance sweep.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sqlite3
27
+
28
+ NAME = "M031_dead_letter_operations"
29
+ DB_TARGET = "memory"
30
+
31
+ DDL = """
32
+ CREATE TABLE IF NOT EXISTS dead_letter_operations (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ original_op_id TEXT NOT NULL,
35
+ operation_type TEXT NOT NULL DEFAULT 'M018',
36
+ content TEXT,
37
+ metadata_json TEXT,
38
+ error TEXT,
39
+ attempt_count INTEGER,
40
+ first_attempt_at REAL,
41
+ dead_lettered_at REAL NOT NULL DEFAULT (unixepoch('now')),
42
+ profile_id TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_dlq_profile
45
+ ON dead_letter_operations (profile_id);
46
+ CREATE INDEX IF NOT EXISTS idx_dlq_op_id
47
+ ON dead_letter_operations (original_op_id);
48
+ """
49
+
50
+
51
+ def apply(conn: sqlite3.Connection) -> None:
52
+ """Create the dead_letter_operations table and indexes idempotently."""
53
+ conn.executescript(DDL)
54
+
55
+
56
+ def verify(conn: sqlite3.Connection) -> bool:
57
+ """Return True only when the complete M031 contract is present."""
58
+ table = conn.execute(
59
+ "SELECT 1 FROM sqlite_master WHERE type='table' "
60
+ "AND name='dead_letter_operations'"
61
+ ).fetchone()
62
+ if table is None:
63
+ return False
64
+ columns = {
65
+ row[1]
66
+ for row in conn.execute(
67
+ "PRAGMA table_info(dead_letter_operations)"
68
+ ).fetchall()
69
+ }
70
+ required = {
71
+ "id",
72
+ "original_op_id",
73
+ "operation_type",
74
+ "content",
75
+ "error",
76
+ "attempt_count",
77
+ "dead_lettered_at",
78
+ "profile_id",
79
+ }
80
+ return required <= columns
@@ -0,0 +1,188 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+
4
+ """M032 — durable, append-only receipts for canonical write commands.
5
+
6
+ The receipt ledger is profile-isolated at the client idempotency boundary.
7
+ ``command_id`` and ``journal_id`` remain globally durable identifiers; a
8
+ client-supplied idempotency key is unique only with its target profile. An
9
+ operation id is a projection label (for example, ``update:<fact_id>``), not a
10
+ durable command identifier, so it is deliberately indexed but not unique.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sqlite3
16
+
17
+ NAME = "M032_write_coordinator_admission"
18
+ DB_TARGET = "memory"
19
+
20
+ DDL = """
21
+ CREATE TABLE IF NOT EXISTS write_commits (
22
+ commit_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ command_id TEXT NOT NULL UNIQUE,
24
+ journal_id TEXT NOT NULL UNIQUE,
25
+ command_kind TEXT NOT NULL,
26
+ request_hash TEXT NOT NULL,
27
+ profile_id TEXT NOT NULL,
28
+ idempotency_key TEXT NOT NULL,
29
+ operation_id TEXT NOT NULL,
30
+ receipt_json TEXT NOT NULL,
31
+ committed_at REAL NOT NULL,
32
+ UNIQUE(profile_id, idempotency_key)
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_write_commits_committed_at
35
+ ON write_commits (committed_at);
36
+ CREATE INDEX IF NOT EXISTS idx_write_commits_operation_id
37
+ ON write_commits (operation_id);
38
+ CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_update
39
+ BEFORE UPDATE ON write_commits
40
+ BEGIN
41
+ SELECT RAISE(ABORT, 'write_commits receipts are immutable');
42
+ END;
43
+ CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_delete
44
+ BEFORE DELETE ON write_commits
45
+ BEGIN
46
+ SELECT RAISE(ABORT, 'write_commits receipts are immutable');
47
+ END;
48
+ """
49
+
50
+ _CREATE_TABLE = DDL.split(";", 1)[0]
51
+ _CREATE_COMMITTED_AT_INDEX = (
52
+ "CREATE INDEX IF NOT EXISTS idx_write_commits_committed_at "
53
+ "ON write_commits (committed_at)"
54
+ )
55
+ _CREATE_OPERATION_ID_INDEX = (
56
+ "CREATE INDEX IF NOT EXISTS idx_write_commits_operation_id "
57
+ "ON write_commits (operation_id)"
58
+ )
59
+ _CREATE_UPDATE_TRIGGER = """
60
+ CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_update
61
+ BEFORE UPDATE ON write_commits
62
+ BEGIN
63
+ SELECT RAISE(ABORT, 'write_commits receipts are immutable');
64
+ END
65
+ """
66
+ _CREATE_DELETE_TRIGGER = """
67
+ CREATE TRIGGER IF NOT EXISTS trg_write_commits_immutable_delete
68
+ BEFORE DELETE ON write_commits
69
+ BEGIN
70
+ SELECT RAISE(ABORT, 'write_commits receipts are immutable');
71
+ END
72
+ """
73
+
74
+
75
+ def apply(conn: sqlite3.Connection) -> None:
76
+ """Create or safely upgrade the profile-scoped append-only receipt ledger."""
77
+ if verify(conn):
78
+ return
79
+ if not _table_exists(conn):
80
+ _create_current_schema(conn)
81
+ return
82
+ _rebuild_legacy_schema(conn)
83
+
84
+
85
+ def repair(conn: sqlite3.Connection) -> None:
86
+ """Repair a completed provisional M032 in developer/test databases."""
87
+ apply(conn)
88
+
89
+
90
+ def verify(conn: sqlite3.Connection) -> bool:
91
+ """Return true only when the full profile-safe ledger contract exists."""
92
+ if not _table_exists(conn):
93
+ return False
94
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(write_commits)").fetchall()}
95
+ required = {
96
+ "commit_sequence",
97
+ "command_id",
98
+ "journal_id",
99
+ "command_kind",
100
+ "request_hash",
101
+ "profile_id",
102
+ "idempotency_key",
103
+ "operation_id",
104
+ "receipt_json",
105
+ "committed_at",
106
+ }
107
+ if not required <= columns:
108
+ return False
109
+ if not _has_unique_index(conn, ("command_id",)):
110
+ return False
111
+ if not _has_unique_index(conn, ("journal_id",)):
112
+ return False
113
+ if not _has_unique_index(conn, ("profile_id", "idempotency_key")):
114
+ return False
115
+ if _has_unique_index(conn, ("idempotency_key",)):
116
+ return False
117
+ if _has_unique_index(conn, ("operation_id",)):
118
+ return False
119
+ object_names = {
120
+ row[0]
121
+ for row in conn.execute(
122
+ "SELECT name FROM sqlite_master WHERE name IN (?, ?, ?, ?)",
123
+ (
124
+ "idx_write_commits_committed_at",
125
+ "idx_write_commits_operation_id",
126
+ "trg_write_commits_immutable_update",
127
+ "trg_write_commits_immutable_delete",
128
+ ),
129
+ ).fetchall()
130
+ }
131
+ return object_names == {
132
+ "idx_write_commits_committed_at",
133
+ "idx_write_commits_operation_id",
134
+ "trg_write_commits_immutable_update",
135
+ "trg_write_commits_immutable_delete",
136
+ }
137
+
138
+
139
+ def _table_exists(conn: sqlite3.Connection) -> bool:
140
+ return conn.execute(
141
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='write_commits'"
142
+ ).fetchone() is not None
143
+
144
+
145
+ def _has_unique_index(conn: sqlite3.Connection, columns: tuple[str, ...]) -> bool:
146
+ for index in conn.execute("PRAGMA index_list(write_commits)").fetchall():
147
+ if not index[2]:
148
+ continue
149
+ index_columns = tuple(
150
+ row[2] for row in conn.execute(f"PRAGMA index_info({index[1]})").fetchall()
151
+ )
152
+ if index_columns == columns:
153
+ return True
154
+ return False
155
+
156
+
157
+ def _create_current_schema(conn: sqlite3.Connection) -> None:
158
+ conn.execute(_CREATE_TABLE)
159
+ conn.execute(_CREATE_COMMITTED_AT_INDEX)
160
+ conn.execute(_CREATE_OPERATION_ID_INDEX)
161
+ conn.execute(_CREATE_UPDATE_TRIGGER)
162
+ conn.execute(_CREATE_DELETE_TRIGGER)
163
+
164
+
165
+ def _rebuild_legacy_schema(conn: sqlite3.Connection) -> None:
166
+ """Rebuild only M032's own standalone table under one savepoint."""
167
+ conn.execute("SAVEPOINT m032_profile_scoped_idempotency")
168
+ try:
169
+ conn.execute("DROP TRIGGER IF EXISTS trg_write_commits_immutable_update")
170
+ conn.execute("DROP TRIGGER IF EXISTS trg_write_commits_immutable_delete")
171
+ conn.execute("ALTER TABLE write_commits RENAME TO write_commits_legacy")
172
+ conn.execute("DROP INDEX IF EXISTS idx_write_commits_committed_at")
173
+ conn.execute("DROP INDEX IF EXISTS idx_write_commits_operation_id")
174
+ _create_current_schema(conn)
175
+ conn.execute(
176
+ "INSERT INTO write_commits("
177
+ "commit_sequence, command_id, journal_id, command_kind, request_hash, "
178
+ "profile_id, idempotency_key, operation_id, receipt_json, committed_at"
179
+ ") SELECT commit_sequence, command_id, journal_id, command_kind, request_hash, "
180
+ "profile_id, idempotency_key, operation_id, receipt_json, committed_at "
181
+ "FROM write_commits_legacy"
182
+ )
183
+ conn.execute("DROP TABLE write_commits_legacy")
184
+ except BaseException:
185
+ conn.execute("ROLLBACK TO m032_profile_scoped_idempotency")
186
+ conn.execute("RELEASE m032_profile_scoped_idempotency")
187
+ raise
188
+ conn.execute("RELEASE m032_profile_scoped_idempotency")